Landaro

Project Documentation

Web application for non-EU citizens arriving in Romania

Generated on 2026-10-19
7 chapters · 75 documents

1Project Overview

1.1README.md

Landaro

Web application for non-EU citizens coming to Romania. We help them get oriented: useful news, legal guidance, a free account, and Romanian language courses.

This folder holds the project documentation. In this phase we write documentation, not code. Each student documents one module, in their own folder, without stepping on anyone else's. Code comes later, after the documentation is complete and reviewed.

Who we build for (short)

  • Non-EU individual, newly arrived in or about to come to Romania.
  • Doesn't speak the language, doesn't know the legal steps, doesn't know where to start.
  • Wants clear information, in their language, and concrete steps.

Modules (phase 1) and who owns them

#ModuleFolderOwnerShort
1Newsmodules/news/_(to assign)_list of useful news, categories, article page
2Legal guidancemodules/legal/_(to assign)_advice + "ask a legal consultant"
3Accounts & Adminmodules/accounts-admin/_(to assign)_free individual registration + admin panel (admin & user)
4Romanian coursesmodules/courses/_(to assign)_10 days free, then move to a talk with the company
Write your name in the Owner column when you take your module. One module = one owner.

Shared foundation (students do NOT modify it)

The shared/ folder holds what everyone depends on: glossary, documentation conventions, the common data model, the API contract, and roles.

  • It is read-only for students. If you need a change there (e.g. a new field in the

users table), do not edit it — write a request in shared/change-requests.md and we discuss it together.

  • This prevents two people from changing the same foundation at the same time.

Golden anti-conflict rule

  1. You work only in your module's folder and in shared/change-requests.md.
  2. Anything that touches someone else (a shared field, a shared endpoint, a role) →

a request in shared/change-requests.md, not a direct change.

  1. If two modules need to "talk" to each other, write it at the boundary: what one

provides and what the other expects, in each module's "Dependencies" section.

How you start (each student)

  1. Read this README + the whole shared/ folder.
  2. Open your module's folder and read its README.md (the start guide).
  3. Fill in your module's documentation.md, using

shared/doc-template.md as the structure.

  1. When you have a draft, ask for feedback before going further.

Logical reading order

README.md (this one) → shared/ → modules/<your module>/README.md → write documentation.md.

1.2_decisions.md

Landaro — Consolidated decisions

← Landaro

Single source of truth for the decisions settled in _questions.md (rounds 1–3). The module documentation reflects these. Written in English (per project convention); confirmed with Florin on 2026-10-12. If something here changes, update the affected module docs in the same change.

Global

  • Stack: Angular frontend, MySQL database, REST API in plain PHP (OOP) — no framework

(no Laravel/CodeIgniter), no Composer. Clean classes, models, services.

  • No external package manager: JWT is hand-written (our own sign/verify functions). PHPMailer

is added manually into lib/PHPMailer/ and required by our own PSR-4-style autoloader. Project folder structure is at the implementer's discretion.

  • Auth: JWT bearer token in the Authorization header, issued on login. sessions table

tracks issued tokens for logout/expiry. Session 7 days (1 day if "Remember me" unchecked); activation link 24h; password-reset link 1h.

  • Email: real SMTP via PHPMailer (never PHP mail()).
  • UI languages: English + French. Translations live in frontend JSON files (en.json,

fr.json), versioned with the code and shipped by the Angular build. A language selector is in the header; the preference is saved on the account (users.locale, default en) and in localStorage. Admin-editable translations without redeploy = later phase.

  • Uploads: local disk, in an uploads/ folder. Images only for v1 (png/jpg/webp), ~5 MB max.
  • First admin: created by seed — test@test.com / test. Changeable later via the profile

page.

  • Pagination: default pageSize 10; selectable 10 / 25 / 50 / 100; max 100.
  • Dates: stored in UTC, displayed in Europe/Bucharest.
  • Settings: a global key-value settings table + a "Settings" screen in admin. Each module

reads the keys it needs (e.g. legal.max_questions_per_day, legal.disclaimer_text, courses.free_period_days, test pass threshold).

  • Audit logging: admin actions (role changes, free-period extensions, lead validation, etc.)

are logged — who, when, what.

Shared users — new fields (via change request)

  • locale — en / fr, default en.
  • has_full_access — boolean, default false. Set true when an admin marks a lead valid; grants

unlimited access to everything and suppresses the lead modal.

Access model (cross-module)

  • A logged-in user has access to a gated feature when: inside a valid free period OR

has_full_access = true.

  • When the free period expires (and has_full_access is false): Course lessons lock AND

Legal "ask a consultant" (real-consultant chat) is cut off. News stays public; the Legal FAQ stays readable. The lead modal appears on every navigation until a lead is submitted.

  • A lead marked valid by an admin sets has_full_access = true → unlimited access to courses,

consultancy, news; the modal never shows again.

  • An admin can also extend a single user's free period by pushing free_period_ends_at into the

future (logged).

Accounts & Admin

  • Password rule: min 8 chars, at least one letter and one digit; UI shows a strength progress bar.
  • Self-deactivation allowed; legal questions kept (anonymized) for consultant history; course

progress dropped.

  • Consultant/admin accounts are created only by an admin changing an existing user's role.
  • Changing email requires re-verifying the new address (old email kept until confirmed).

News

  • Two article types: curated (external extract) and original (in-house). Curated requires

a dedicated paste field + source link input; auto-trimmed to 50 words with an ellipsis "…" at the last whole word; source credited with name + link back.

  • One optional feature image; a placeholder is shown when absent.
  • Admin manages categories and tags freely.
  • Related articles = same category, most recent, excluding the current one.
  • published_at in the future ⇒ the article auto-appears when that moment passes.

Legal

Two separate parts:

  1. General FAQ (ready-made) — written by admin and consultants; subject + own Legal category +

body + optional image (placeholder when absent); publicly readable.

  1. Ask a consultant — a logged-in user posts a question; a consultant self-claims it (first

click owns it; others can't take it). An admin may also assign/reassign/free a question. The conversation is a chat between the two people (mIRC-style: either side posts any number of messages, shown chronologically, no reply threading).

  • Question statuses: open (unclaimed) → claimed (a consultant took it) → resolved. Both the

user and the consultant can mark resolved; the user can reopen it; an admin can reopen too.

  • Daily limit of questions per user is a setting (default 3), reset at local midnight

(Europe/Bucharest).

  • The ask-a-consultant feature is gated by the access model above (lost on free-period expiry

unless has_full_access). The FAQ is not gated.

  • Disclaimer: default text shipped by us, editable from an admin Settings section.
  • Statistics screen "Legal — Statistics" in admin, plus key figures on the general dashboard:
    • Global: question counts by status (open/claimed/resolved); counts today/this week/this month.
    • Per consultant: claimed, resolved, average time to first reply, average time to resolution.
    • No composite "performance score" — raw metrics only.

Courses

  • Hierarchy: course → modules → lessons. Users enroll at the module level (explicit enroll).
  • Free period is global per account (10 days from the first lesson started anywhere), set by

Courses triggering Accounts to write users.free_period_ends_at = now + 10 days.

  • Lessons: text/HTML + images, and video via YouTube embed played through a custom player

(so the raw YouTube video isn't trivially reachable; videos are private).

  • A module has lessons and tests. Module completed = all its lessons completed AND all its

tests passed.

  • Tests: multiple-choice (single correct answer), questions set in admin, pass mark ≥ 75%.

Every attempt is recorded (history), unlimited attempts. The certificate states on which attempt the pass was achieved.

  • On module completion: a visual "Completed" check next to the user, a certificate on the profile

page with a PDF download button, and an email with the link (exportable as PDF). Certificate contains: user name, module name, completion date, a unique verification ID, the attempt number, and Landaro logo/name.

  • After free-period expiry (no full access): lessons lock, progress stays read-only, and a lead

modal appears on every navigation until a lead is submitted — forceful but friendly.

  • Company lead: user submits via the modal (a textarea). One lead record per user (upsert). Admin

sees it in the panel AND is notified by email of a new lead. Admin marks it new / valid / invalid; valid grants has_full_access (see access model).

Still soft / may change

  • Free period global-per-account (not per-module) — confirmed for now, may revisit.
1.3_questions-build.md

Landaro — Build Questions (before writing code)

I read the entire documentation (shared foundation + all four modules). Before creating any folders or code, I need your decisions on the points below. The docs say we must not improvise. How to answer: write your reply directly under each question, on the **Answer:** line. My recommendation is noted at the end of each item — if a recommendation is fine, just write "ok" or "use recommendation".

1. Scope and sequencing

This is a large build (4 modules, ~20 tables, auth, email, certificates/PDF, i18n). How do you want to approach it?

  • A) Build everything end-to-end in one pass (backend + frontend for all four modules).
  • B) Go module by module, starting with Accounts & Admin (the foundation everyone depends

on), with your sign-off at each step before moving on.

Recommendation: B — matches the docs ("Accounts & Admin is the foundation module") and gives checkpoints.

Answer: B option


2. Where the code lives

The current landaro/ folder holds only documentation (modules/, shared/). Proposed flat layout inside landaro/:

  • landaro/backend/ — the PHP API
  • landaro/frontend/ — the Angular app
  • landaro/database/ — SQL schema + seed

Is this layout okay, or do you want a different location/name?

Recommendation: use the layout above.

Answer: is perfect, ageree.


3. Environment / tooling versions

To match your machine and avoid guessing:

  • PHP version (e.g. 8.1 / 8.2 / 8.3)?

Answer: i dont know really, check my mamp server

  • How is PHP served — built-in php -S, or Apache/Nginx?

Answer: apache

  • MySQL or MariaDB, and version?

Answer: i dont know, check my mampserver, but I preffer mysql

  • Database name / user to assume in config (e.g. landaro)?

Answer: landaro

  • Angular version (latest is 18/19)? Standalone components or NgModules?

Answer: i preffer last version

  • Node version available for the Angular build?

Answer:sure

Recommendation: PHP 8.3 served via php -S for dev, MySQL 8, db landaro, latest Angular with standalone components, current LTS Node.


4. PHP without Composer — autoloader + libraries

The docs say "no Composer," PSR-4-style autoloader, PHPMailer dropped into lib/PHPMailer/. Since I cannot composer require, how should I bring in third-party libs (PHPMailer, and a PDF lib later)?

  • A) I download the library source into backend/lib/... for you.
  • B) I scaffold the structure and you drop the library files in manually.

Recommendation: A — I fetch PHPMailer (and FPDF) sources into backend/lib/ so it runs out of the box.

Answer: choose A option


5. SMTP / email

Email uses real SMTP via PHPMailer (never PHP mail()). For local development:

  • A) You have SMTP credentials I should wire into a config/env file (host/port/user — I will

NOT hardcode secrets; you fill them in).

  • B) I set up config for a local catcher (MailHog / Mailpit) and you provide real credentials

later.

Recommendation: B for dev (Mailpit), with a clearly marked config file for real credentials.

Answer: A version, I already have the email server


6. Certificates (PDF)

Courses issues a certificate as a downloadable PDF plus an email link. With no Composer, a PDF library also has to be added manually.

  • A) Add FPDF (lightweight, single-file, no deps) into backend/lib/ and generate the PDF

on the backend.

  • B) Generate the certificate as HTML and let the browser produce the PDF (print-to-PDF),

avoiding a backend PDF lib.

Recommendation: A — FPDF, so the email link and download both serve a real PDF.

Answer: version B


7. Open TODOs in the docs (must not guess)

  • 7a. Courses — one test per module, or several? The tests schema allows several per

module; this changes the "all tests passed" completion rule. Recommendation: one test per module for v1 (completion = lessons done + that test passed). Answer:on test per module

  • 7b. News is_featured — change request #1 is still open (not approved). Include it in v1

or leave it out? Recommendation: leave it out for v1 (it is not approved yet). Answer:include it also

  • 7c. Legal daily-limit scope — does the daily limit count all questions created that day

regardless of status (including resolved)? Recommendation: yes — count every question created today, any status. Answer: yes

  • 7d. Custom YouTube player — minimum behavior for v1? (e.g. hide title, hide YouTube logo,

disable related videos, block right-click) Recommendation: embed with modestbranding, rel=0 (no related videos), hidden title, and our own play/pause controls overlay — enough to keep the raw video from being trivially grabbed. Answer: agree


8. Anything else you want changed before I start?

Free text — constraints, naming, things to avoid, priorities.

Answer: only if you have other questions.

2Shared Foundation

2.1shared/README.md

Shared — Landaro common foundation

← Landaro

This holds what all modules depend on. Goal: every student starts from the same rules, the same names, the same data model, so modules fit together at the end without clashes.

⚠️ Read-only for students

Do not edit files in shared/. If you need a change (a new field, a new role, a shared endpoint), write a request in change-requests.md. We discuss it and I apply it (or we do it together). This way two people never fight over the same foundation.

What you find here

FileWhat it contains
glossary.mdproject terms, with the same meaning for everyone
doc-conventions.mdhow we write documentation (format, rules)
doc-template.mdthe documentation template you copy
data-model.mdcommon tables (users, roles) + rules
api-contract.mdhow a request/response looks, errors, auth
roles.mdthe roles (visitor, user, admin) and what each can do
change-requests.mdwhere you request changes to shared/

How you use this folder

  1. Read it first, before your module.
  2. When you document, reuse the names from the glossary and the tables from

data-model.md instead of inventing new ones. If the users table already has email, don't create mail.

  1. If something is missing → change-requests.md, don't improvise in your module.
2.2shared/roles.md

Shared Roles

← Shared

Who can do what in Landaro. Read-only for students. Every module must use these exact roles. Need a new role or a permission change? Write a change request.

The roles

RoleWhoLogged in?
visitoranyone browsing the public siteno
userregistered non-EU individualyes
adminplatform administratoryes
consultantlegal consultant answering questionsyes

What each role can do (high level)

visitor

  • Read public news.
  • Read public info / legal advice pages.
  • Register for a free account.
  • Cannot: send legal questions, access courses, enter the admin panel.

user

  • Everything a visitor can do, plus:
  • Manage their own profile.
  • Send legal questions to a consultant and read the answers.
  • Access Romanian courses during the free period (and see when it ends).
  • See their own area in the admin panel (their data only).

admin

  • Manage news (create, edit, publish, delete articles).
  • Manage users (view, activate, block).
  • See and route legal questions.
  • Manage courses and lessons.
  • Full access to the admin panel.

consultant

  • See legal questions assigned to them.
  • Reply to legal questions.
  • Cannot manage users, news, or courses.

Rules for module authors

  • For every screen and every endpoint, state the required role.
  • "Own data only" matters: a user sees only their own profile/questions, never others'.
  • If a feature needs a role not listed here, that's a change request, not a new role invented in your module.
2.3shared/data-model.md

Shared Data Model

← Shared

The common tables every module relies on. Read-only for students. If you need a new table or field, request it in change-requests.md. Each module documents its own module-specific tables inside its own folder, but the tables below are shared and must not be duplicated or renamed.

Conventions

  • Table and field names: English, lowercase, snake_case.
  • Every table has id (primary key) and created_at, updated_at timestamps.
  • Foreign keys are named <entity>_id (e.g. user_id).
  • Dates/times are stored in UTC.

users (owned by: Accounts & Admin module)

The single source of truth for people with an account. Other modules reference it by user_id; they do not create their own user tables.

FieldTypeNotes
idintegerprimary key
emailstringunique, used for login
password_hashstringnever store plain passwords
full_namestringuser's name
countrystringcountry of origin (non-EU)
rolestringone of the roles in roles.md
statusstringactive / pending / blocked
free_period_ends_atdatetimewhen the 10-day free course period ends (nullable)
created_atdatetime
updated_atdatetime

roles (reference)

Roles are a fixed, small list described in roles.md. We store the role as a string on users.role. If we later need more complex permissions, that is a change request.

How modules connect to shared data

  • News, Legal, Courses all point to a user via user_id → users.id.
  • A module must not add columns to users on its own. Need a field? Change request.
  • Example: the Courses module needs to know when the free period ends → it reads

users.free_period_ends_at; it does not create its own copy.

Where module-specific tables go

Tables that belong to one module only (e.g. articles, legal_questions, courses, lessons) are documented inside that module's folder, not here. List them in your module's "Data used" section and note that they reference users.id where needed.

2.4shared/api-contract.md

Shared API Contract

← Shared

How all modules talk to the backend, so every endpoint looks and behaves the same. Read-only for students. This is about shape and rules, not the full endpoint list — each module documents its own endpoints following these rules.

Base

  • All endpoints live under /api.
  • Routes are English, lowercase, plural nouns: /api/articles, /api/legal-questions.
  • Methods: GET (read), POST (create), PUT/PATCH (update), DELETE (remove).

Request / response format

  • Everything is JSON.
  • Request bodies and response bodies use the field names from data-model.md.
  • Successful response:
{ "data": { }, "error": null }
  • A list response:
{ "data": [ ], "meta": { "page": 1, "pageSize": 20, "total": 134 }, "error": null }

Errors (same shape everywhere)

{ "data": null, "error": { "code": "validation_error", "message": "Email already used" } }

Common code values: validation_error, not_found, unauthorized, forbidden, rate_limited, server_error. The message is human-readable (English).

HTTP status codes

CodeWhen
200OK (read/update)
201Created
400Validation error
401Not logged in
403Logged in but not allowed (wrong role)
404Not found
429Too many requests (rate limit)
500Server error

Authentication

  • A logged-in request carries a token (how exactly is defined by the Accounts module).
  • The backend knows the current user and their role from that token.
  • Modules do not invent their own login — they rely on the Accounts module.

Rules for module authors

  • Reuse this envelope (data / error / meta) for every endpoint.
  • Reuse the error code list above; if you need a new code → change request.
  • State the required role per endpoint (see roles.md), e.g. "admin only".
2.5shared/doc-conventions.md

Documentation Conventions

← Shared

Simple rules so everyone's documentation looks the same and is easy to read.

Language

  • All documentation is written in English. Even if we talk in another language,

everything written in these files is English. No exceptions.

  • Technical names (tables, fields, API routes): English, lowercase with _.

Format

  • We write in Markdown (.md).
  • Your module documentation lives in modules/<module>/documentation.md.
  • If a file grows beyond ~100 lines, split it into several files with clear names

(e.g. documentation.md + screens.md + data.md) and add a table of contents with links.

  • One idea per paragraph. Short sentences. No filler.

Required structure

Start from doc-template.md. Don't skip sections — if one doesn't apply, write "not applicable" and why.

Content rules

  • Use the glossary. Same words everywhere (User (individual), not "client").
  • Don't reinvent the data. If you need a field on the user, check data-model.md

first. If it's missing → change request.

  • Write rules, not just screens. E.g. "a user can send max. 3 legal questions per

day" is a rule; it must be written explicitly.

  • Concrete examples. Put real examples (a real news title, a step-by-step flow).
  • Mark the unknowns. If you don't know something, write TODO: to clarify — don't guess.

Dependencies between modules

Every documentation has a Dependencies section. There you write clearly:

  • "I receive from the Accounts module: the logged-in user (id, role)."
  • "I provide to module X: the list of published articles."

This way boundaries are written, not assumed.

Before you say "done"

  • You filled in every section of the template.
  • You used the glossary terms.
  • You listed the dependencies.
  • You did not modify anything in shared/ directly.
2.6shared/doc-template.md

Module documentation: <MODULE NAME>

Template. Copy this content into modules/<module>/documentation.md and fill it in. Don't leave sections empty — if one doesn't apply, write "not applicable" + why.

Owner: <your name> Module: <news / legal / accounts-admin / courses> Last updated: <YYYY-MM-DD>

1. Purpose

What the module solves, for whom, why it's useful. 3–5 sentences.

2. Users & roles

Who uses the module (visitor / user / admin / consultant) and what each does. Use the roles from ../../shared/roles.md.

3. Features (list)

The list of things the module can do. Short, bullet points.

4. Flows (step by step)

For each important feature, the concrete steps. Example: "User → clicks Register → fills the form → receives an email → account active".

5. Screens / pages

What pages the module has and what each contains (no fine design, just content and actions).

6. Data used

Which tables/fields you use. First check ../../shared/data-model.md. If you need something new → request it in ../../shared/change-requests.md (do NOT add it here directly).

7. Business rules

Clear rules, not just screens. E.g. limits, validations, who may do what, what happens on expiry.

8. Dependencies

  • I receive from: <which module / which data>
  • I provide to: <which module / which data>
  • I depend on shared: <what from roles/data-model/api-contract>

9. Messages & errors

What messages the user sees (success, error). E.g. "Email already used", "Free period expired".

10. Open questions (TODO)

What's not clear yet. Each line starts with TODO:.

2.7shared/glossary.md

Landaro Glossary

← Shared

Project terms, with the same meaning for everyone. Use exactly these words in the documentation (and later in the code), so we all speak the same language.

TermMeaning
Landarothe name of the application
Visitorsomeone not logged in; sees public content (news, info pages)
User (individual)non-EU individual, with a free account
Adminplatform administrator; manages content and users
Legal consultantthe person who answers users' legal questions
Modulea major part of the app (news, legal, accounts, courses)
Articlean individual news item
Legal questionthe message a user sends to the consultant
Coursethe Romanian language course
Lessona unit inside a course
Free period10 days of free access to courses for a new user
Company leadthe moment a user is invited to bring their company to a talk
Admin panelthe administration area (for admin and, partly, for the user)
Rolewhat rights someone has: visitor / user / admin (see roles.md)

Naming rules

  • Write business terms in English (as above).
  • Write technical names (tables, fields, endpoints) in English, lowercase, as in

data-model.md and api-contract.md (e.g. users, articles, legal_questions).

  • If a new term appears in your module, add it via a change request, so it stays

the same everywhere.

2.8shared/change-requests.md

Change Requests to shared/

← Shared

This is the only place where you ask for a change to the shared foundation (data model, roles, API contract, glossary). You do not edit those files yourself. Add a new row at the top of the table, fill the fields, and tell the mentor. We discuss it and apply it together.

Why this exists

If two students both edit users or roles at the same time, we get conflicts and confusion. Funnelling every shared change through this one list keeps the foundation consistent for everyone.

How to request

  1. Add a row at the top of the table below (newest first).
  2. Give it the next free #.
  3. Status starts as open. The mentor changes it to approved / applied / rejected.
  4. Do not change shared/ files yourself even after approval unless told to.

Requests

#DateRequested byModuleWhat / whyStatus
32026-10-12FlorinCourses / allAdd has_full_access (boolean, default false) to users. When an admin marks a company lead valid, set it true → unlimited access to courses, consultancy and news, and the lead modal stops showing. All modules check "valid free period OR has_full_access".approved
22026-10-12Florinall (i18n)Add locale (string, en/fr, default en) to users, to store the user's preferred UI language.approved
1YYYY-MM-DD<name><module>example: add phone to users because registration needs itopen

Example of a good request

Add field locale (string) to users. Reason: the Courses module needs to know the user's preferred interface language to show lessons in the right language. Default en.

Short, says what and why, names the module. That's all we need to decide.

3Module — Accounts & Admin

3.1modules/accounts-admin/README.md

Module: Accounts & Admin — Start Guide

← Landaro · Shared

Owner: _(GB)_

Before you write anything, read the main README and the whole shared/ folder. Then write your documentation in your documentation as a set of files (see overview.md as the entry point), using the structure from doc-template.md.

What this module is about

The foundation everyone else leans on: free registration for individuals, login, the user's own profile area, and the admin panel (admin manages users and content; the user sees their own area). This module owns the users table.

In scope

  • Free registration for a non-EU individual (User).
  • Login / logout, password handling (hashed, never plain).
  • The user's own profile + their area in the admin panel.
  • Admin panel: list users, activate / block, assign roles.
  • Being the single source of "who is logged in and what role they have".

Out of scope (don't document these here)

  • News content → News module.
  • Legal questions → Legal module.
  • Courses & the free period logic → Courses module (but the field

users.free_period_ends_at lives on the shared users table).

Special responsibility

Because you own users, other modules read it through user_id. They cannot add fields to it. When another module needs a new user field, it goes through change-requests.md and we add it to data-model.md together. Document how auth works clearly — everyone else depends on it.

Questions to answer while documenting

  • Exact registration flow (form fields, email confirmation? activation?).
  • How login works and how other modules know the current user + role.
  • What the admin can do to a user (activate, block, change role) and the rules.
  • What the user sees vs. what the admin sees in the admin panel.
  • Password rules and basic security (hashing, no plain passwords).

Anti-conflict reminder

You own users, but shared changes still go through change-requests.md so the whole team stays in sync.

3.2modules/accounts-admin/overview.md

Accounts & Admin — Overview

← Landaro · Shared

Owner: GB Module: accounts-admin Last updated: 2026-10-12

Accounts & Admin is the foundation module of Landaro. It owns identity for the whole platform: who a person is, whether they are logged in, and what role they hold. Every other module (News, Legal, Courses) leans on it to know the current user and gate what they can do. This module owns the shared users table.

Documentation in this module

Each section of the template lives in its own file so it is easy to read and maintain:

FileWhat it covers
purpose.mdWhat the module solves and why it matters
users-and-roles.mdThe four roles and how access control works
features.mdThe list of things the module can do
flows.mdStep-by-step registration, login, activation, access
screens.mdThe pages and what each shows / does
data-model.mdThe users, sessions, and account_tokens tables
business-rules.mdValidation, security, and permission rules
messages.mdSuccess and error messages the user sees
dependencies.mdWhat this module gives to and takes from others
open-questions.mdDecisions still open, marked as TODO

Reading order

Start with purpose.md → users-and-roles.md → flows.md → data-model.md, then the rest as needed.

3.3modules/accounts-admin/purpose.md

Accounts & Admin — Purpose

← overview.md

What this module solves

Accounts & Admin is the identity backbone of Landaro. Before a non-EU newcomer can ask a legal consultant a question, start a Romanian course, or save anything to come back to, the platform has to know who they are. This module provides that: a free account, a secure login, and a single, trustworthy answer to "who is this person and what are they allowed to do".

Who it is for

  • The newcomer (User). Someone who just arrived in Romania, or is about to, from outside

the EU. They need a free, low-friction way to register and a profile that remembers them, so the help Landaro offers (legal answers, courses) is tied to one identity.

  • The platform itself. Every other module needs a reliable way to check "is this person

logged in, and what is their role?" before showing content or accepting an action. Accounts & Admin is the one place that answers that question.

  • The administrator. A staff member who manages the community: approving new accounts,

blocking abusive ones, and assigning roles such as consultant.

Why it matters

If identity is weak, everything built on top of it is weak. A newcomer could lose access to their own legal questions, an unverified account could flood a consultant with spam, or a regular user could stumble into another person's private data. By centralizing registration, login, roles, and the admin panel in one module — and by owning the users table as the single source of truth — Landaro keeps access control consistent and auditable across the whole app. Every other module delegates identity here instead of reinventing it, which is exactly what keeps the system coherent as it grows.

In one line

Accounts & Admin lets the right person in, keeps the wrong person out, and tells every other module who it is talking to.

3.4modules/accounts-admin/users-and-roles.md

Accounts & Admin — Users & roles

← overview.md

Landaro has four roles. Every account holds exactly one role at a time, stored as a string on the users table (see data-model.md). The role decides what the person can see and do across the whole platform, not just inside this module. Roles are defined centrally in ../../shared/roles.md; this module is the one that assigns and enforces them.

The four roles

Visitor

Anyone who is not logged in. They can read public content (published news, published legal guidance) and register for a free account. They cannot ask a consultant a question, open a course, or reach any admin screen.

User (individual)

A registered, activated newcomer. On top of everything a visitor can do, a user can manage their own profile, ask legal questions, access the Romanian course during the free period, and view their personal area. A user can only ever see and edit their own data.

Consultant

A legal specialist. A consultant sees only the legal questions an admin has routed to them and can post answers. They have no control over news, accounts, or routing — they answer, nothing more.

Admin

Full control of the platform. An admin manages accounts (activate, block, assign roles), manages and routes legal questions, and manages news and courses. Admin is the only role that can change another account's role or status.

How access control works

  1. One role per account. The role field holds a single value: visitor, user,

admin, or consultant. There is no multi-role account.

  1. Checked on every request. Every screen and every API endpoint checks the active

session's role before doing anything. An unauthorized request is redirected to login or rejected, never silently allowed.

  1. "Own data only" for users. A regular user's queries are always scoped to their own

user_id. They cannot read or edit another member's profile or questions under any circumstances.

  1. Admin area is guarded. Only admin (and, for their own inbox, consultant) can load

administrative interfaces. A user hitting an admin URL directly is denied.

  1. Roles are fixed, not ad-hoc. New roles are not invented inside a feature. Changing the

role hierarchy is a platform-wide decision and goes through ../../shared/change-requests.md.

Role capability summary

CapabilityVisitorUserConsultantAdmin
Read public content✓✓✓✓
Register / log in✓✓✓✓
Manage own profile—✓✓✓
Ask a legal question—✓——
Answer routed questions——✓—
Manage users / roles———✓
Route legal questions———✓
Manage news & courses———✓
3.5modules/accounts-admin/features.md

Accounts & Admin — Features

← overview.md

The module splits into two groups: what a newcomer uses to manage their own account, and what an admin uses to manage everyone.

Account features (for the user)

Register

A visitor creates a free account with full name, email, password, and country of origin. The account starts in pending status and must be activated by email before it can be used. See the registration flow.

Login / Logout

A registered, activated member signs in with email and password. A successful login opens a session and issues a token carrying the user's id and role, which the rest of the platform reads. Logout ends the session and invalidates the token.

Profile

A private page where a logged-in user views and updates their own details — full name, country, email, and password. It also surfaces read-only account facts such as current role and free-period status (the latter owned by the Courses module).

Admin features (for the admin)

Admin user list

A searchable, filterable table of every account, with name, email, country, role, status, and creation date. The entry point for all member management.

Activate / Block

An admin can activate an account that is stuck in pending, or block an account to immediately cut off its access. Blocking is reversible (unblock), and a blocked account cannot log in.

Assign role

An admin can change a member's role — for example promoting a trusted user to consultant, or granting admin. This is the only way a role changes, and only an admin can do it.

Extend free period

From a user's detail screen an admin can push free_period_ends_at into the future, granting a few extra free days. The action is recorded in the audit log.

Grant full access

When an admin marks a company lead valid (in the Courses lead list), the user's has_full_access becomes true — unlimited access to courses, consultancy, and news, with no more lead modal. This is the account-side effect of a Courses action.

Settings

An admin edits platform-wide settings (key-value) from a Settings screen — e.g. the Legal daily question limit, the Legal disclaimer text, the Courses free-period length, and the test pass threshold. Each module reads the keys it needs.

Language

Every user can pick a UI language (English or French) from the header selector; the choice is saved on their account (locale) and in localStorage.

Cross-cutting responsibility

Beyond these screens, the module has one job every other part of Landaro depends on: being the single source of "who is logged in, what role they hold, and whether they have access". News, Legal, and Courses never implement their own login; they read identity and the access state (free_period_ends_at, has_full_access) from here. See dependencies.md.

3.6modules/accounts-admin/flows.md

Accounts & Admin — Flows

← overview.md

The concrete, step-by-step paths through the module. Field names and statuses match data-model.md.

1. Registration

  1. Visitor opens the Register page and fills in full name, email, password, password

confirmation, and country of origin, then accepts the terms.

  1. The system validates the input: email must be well-formed and unique, the two password

fields must match, and the password must meet the strength rule.

  1. On success, a new row is created in users with status = 'pending' and the password

stored only as password_hash (never plain text).

  1. The system creates an account_tokens row of type email_activation and emails the user

an activation link containing that token.

  1. The user sees: "Account created successfully. Please check your email to activate it."

2. Email confirmation & activation

  1. The user opens the email and clicks the activation link.
  2. The system looks up the token in account_tokens, checks it is of type

email_activation, not expired, and not already used.

  1. If valid, the system sets the user's status to active and stamps used_at on the

token so it cannot be reused.

  1. The user can now log in. If the token is expired or already used, the system offers to

resend a fresh activation email.

3. Login & token generation

  1. The user submits email and password on the Login page.
  2. The system finds the account by email and verifies the password against password_hash.
  3. It checks the account is allowed to log in: status must be active (not pending, not

blocked).

  1. On success, the system creates a sessions row and issues an authentication token that

carries the user's id and role, then redirects to the profile/dashboard.

  1. On failure it returns a generic "Wrong email or password." (it does not reveal whether the

email exists), or the specific blocked/pending message when that is the cause.

4. How other modules read the current user

  1. A request arrives at News, Legal, or Courses carrying the session token.
  2. That module asks Accounts & Admin to resolve the token into user_id + role.
  3. The module uses the role to decide what to allow — e.g. News lets only an admin publish,

Legal lets only a user ask a question. No other module stores passwords or runs its own login; identity always comes from here.

5. Admin manages an account

  1. Admin opens the Users List, searches/filters, and clicks into a member.
  2. From the detail screen the admin can activate a pending account, block/unblock an account,

or change its role.

  1. Each action updates the matching field on users (status or role), takes effect

immediately, and shows "User status updated."

3.7modules/accounts-admin/screens.md

Accounts & Admin — Screens / pages

← overview.md

What each page contains and what the user can do on it. Content and actions only — no visual design.

1. Register page

  • Shows: Fields for full name, email, password, password confirmation, and country of

origin, plus a checkbox to accept the terms and conditions.

  • Does: Validates the input, creates a new account with pending status, and triggers

the activation email. On error it shows the specific validation message inline.

2. Login page

  • Shows: Email and password inputs, a "Remember me" checkbox, and a "Forgot password?"

link.

  • Does: Verifies credentials, opens a session with the user's role, and redirects to the

profile or the page the user came from. Surfaces blocked/pending/invalid messages when login cannot proceed.

3. User profile page

  • Shows: A read-only overview (registration date, current role, free-period status) and

editable fields for full name, country, email, and password.

  • Does: Lets the logged-in user update their own details, change their password, and log

out. A user only ever sees and edits their own profile.

4. Admin panel — users list

  • Shows: A table of all accounts with name, email, country, role badge, status (active /

pending / blocked), and creation date.

  • Does: Lets the admin search by keyword, filter by role or status, sort, and open a

single member's detail page. Admin-only.

5. Admin panel — user detail & actions

  • Shows: A single member's full picture — account history, submitted legal questions,

course progress, free-period status, has_full_access, assigned role, and activity timestamps.

  • Does: Lets the admin change the member's role, activate a pending account, block/unblock

access, and extend the free period (set a new free_period_ends_at). Changes apply immediately and are written to the audit log. Admin-only.

6. Admin panel — settings

  • Shows: The editable platform settings as key-value pairs, grouped by module (Legal,

Courses, …) — e.g. daily question limit, disclaimer text, free-period days, test pass threshold.

  • Does: Lets an admin update a setting; the change takes effect wherever that key is read.

Admin-only.

Note on the password strength bar

The Register and Profile pages show a live password-strength indicator (progress bar) as the user types, reflecting the rule in business-rules.md.

Note on the language selector

A language selector (English / French) sits in the header on every page; changing it updates the user's locale and the UI language immediately.

3.8modules/accounts-admin/business-rules.md

Accounts & Admin — Business rules

← overview.md

Explicit rules, not just screens. These hold regardless of which UI triggers them.

Identity & registration

  • Unique email. Every account must have a unique email. A second registration with an

existing email is rejected with "Email already used."

  • Activation before use. A new account is created as pending and cannot log in until the

email activation link is used. Only then does status become active.

  • Password strength. A password must meet the minimum strength rule before it is accepted

at registration or reset. (TODO: pin the exact rule — length and character classes.)

Security

  • Never store plain passwords. Passwords are stored only as password_hash. The raw

password is never written to the database or logs.

  • Tokens are hashed and single-use. Session and account tokens are stored as hashes.

Activation and reset tokens expire and are marked used_at once redeemed, so a link works at most once.

  • Generic login failure. A failed login returns "Wrong email or password." without

revealing whether the email exists, to avoid leaking which addresses are registered.

Access & permissions

  • Blocked accounts are locked out. An account with status = 'blocked' cannot log in or

use any authenticated feature, effective immediately.

  • Only admins manage accounts. Changing a role, activating a pending account, or

blocking/unblocking a member can only be done by an admin.

  • Own data only. A regular user can read and edit only records tied to their own

user_id. Any attempt to reach another user's data is denied with "Not allowed."

  • Role is single and fixed. An account holds exactly one role, and roles are not invented

per feature. Changing the role set is a platform decision via ../../shared/change-requests.md.

  • Password strength is shown live. The exact rule is min 8 characters with at least one

letter and one digit. Register and Profile display a strength progress bar as the user types.

  • Email change requires re-verification. When a user changes their email in the profile, the

new address must be confirmed via an activation link before it takes effect; the old email stays active until then.

  • Admin actions are audited. Role changes, activation/blocking, free-period extensions, and

lead validation are written to audit_log (who, when, what).

Access state

  • Access = valid free period OR has_full_access. Every gated feature across the platform

(Course lessons, Legal real-consultant chat) checks this combined condition.

  • Full access is granted by a valid lead. When an admin marks a company lead valid,

has_full_access becomes true for that user — unlimited access everywhere, and the lead modal stops appearing.

  • Admins may extend the free period by moving free_period_ends_at forward; this is logged.

Sessions

  • Sessions expire. When a session token passes expires_at it is invalid; the user must

log in again and sees "Session expired. Please log in again."

  • Remember me. Unchecked, a session lasts ~1 day; checked, it lasts the full 7 days.
3.9modules/accounts-admin/data-model.md

Accounts & Admin — Data model

← overview.md

This module owns three tables. users is the platform's single source of truth for identity; sessions and account_tokens support login and the email/password-reset lifecycle.

users is a shared, owned table: other modules read it through user_id but never add fields to it. A new user field is a platform decision and goes through ../../shared/change-requests.md, then into ../../shared/data-model.md.

users (owned table)

The single source of truth for all platform accounts.

FieldTypeNotes
idintegerprimary key
emailstringunique, used for login
password_hashstringhashed password, never plain text
full_namestringuser's name
countrystringcountry of origin (non-EU)
rolestringvisitor, user, admin, or consultant
statusstringactive, pending, or blocked
localestringpreferred UI language, en or fr, default en
free_period_ends_atdatetimewhen the 10-day free course ends (nullable; written by Courses logic, read by anyone)
has_full_accessbooleandefault false; set true when an admin marks a company lead valid → unlimited access everywhere, no lead modal
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC
locale and has_full_access were added via ../../shared/change-requests.md (requests #2 and #3).

sessions

Tracks active logins. One row per active session; deleting/expiring it logs the user out.

FieldTypeNotes
idintegerprimary key
user_idintegerreferences users.id
token_hashstringhashed session token (the raw token is never stored)
expires_atdatetimeexpiration timestamp in UTC
created_atdatetimetimestamp in UTC

account_tokens

Single-use tokens for email activation and password reset. The type field distinguishes the two so one table covers both lifecycles.

FieldTypeNotes
idintegerprimary key
user_idintegerreferences users.id
token_hashstringhashed single-use token
typestringemail_activation or password_reset
expires_atdatetimeexpiration timestamp in UTC
used_atdatetimeset when the token is redeemed, so it cannot be reused (nullable)
created_atdatetimetimestamp in UTC

settings (owned table)

Global key-value settings edited by an admin from the Settings screen. Each module reads the keys it needs (e.g. legal.max_questions_per_day, legal.disclaimer_text, courses.free_period_days, courses.test_pass_threshold).

FieldTypeNotes
idintegerprimary key
keystringunique setting key, e.g. legal.max_questions_per_day
valuetextthe value, stored as text and parsed by the reading module
updated_byintegerreferences users.id (last admin who changed it, nullable)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

audit_log (owned table)

Records sensitive admin actions for accountability (role changes, free-period extensions, lead validation, etc.).

FieldTypeNotes
idintegerprimary key
actor_idintegerreferences users.id (who did it)
actionstringe.g. role_changed, free_period_extended, lead_validated
target_user_idintegerreferences users.id (who it affected, nullable)
detailstexthuman-readable detail / JSON (e.g. "+5 days", old→new role)
created_atdatetimetimestamp in UTC

Relationships

  • sessions.user_id → users.id (many sessions per user over time).
  • account_tokens.user_id → users.id (many tokens per user over time).
  • settings.updated_by → users.id; audit_log.actor_id / target_user_id → users.id.
  • Other modules reference users.id as a foreign key but never alter the users schema.
3.10modules/accounts-admin/messages.md

Accounts & Admin — Messages & errors

← overview.md

The exact text the user sees, grouped by situation, with when each one fires.

Authentication & login

  • "Wrong email or password." — The credentials do not match any active record. Kept

deliberately generic so it does not reveal whether the email exists.

  • "Account blocked." — A user whose status is blocked tries to sign in.
  • "Please confirm your email address before logging in." — Sign-in attempted on a

pending (unactivated) account.

  • "Session expired. Please log in again." — The session token passed its expires_at or

was invalidated.

Registration & validation

  • "Email already used." — Registration with an email that already exists.
  • "Password is too weak." — The chosen password does not meet the strength rule.
  • "Passwords do not match." — Password and confirmation differ.

Access & permissions

  • "Not allowed." — A user tries to view another user's data or perform an admin-only

action without permission.

Success

  • "Account created successfully. Please check your email to activate it." — Registration

succeeded and the activation email was sent.

  • "Profile updated successfully." — Account details or password change saved.
  • "User status updated." — An admin activated, blocked/unblocked, or re-roled a member.
3.11modules/accounts-admin/dependencies.md

Accounts & Admin — Dependencies

← overview.md

This module sits at the bottom of the stack: it provides identity to everyone and depends on almost nothing above it. Boundaries are declared here, from this module's side.

  • I receive from: nothing upstream. Accounts & Admin is the foundation module and does

not depend on any other module for data. It is the base everyone else builds on.

  • I provide to: every other module — the logged-in user's id and role, the login/token

mechanism, and the access state (free_period_ends_at, has_full_access).

    • News reads id + role to allow only an admin to create/edit/publish articles.
    • Legal reads id + role for the FAQ and the consultant chat, and the access state to gate the

real-consultant chat (lost on free-period expiry unless has_full_access).

    • Courses reads id + role, triggers me to set free_period_ends_at on first lesson, and reads

both access fields to lock lessons / show the lead modal.

    • All modules read locale for the UI language, and read settings for their configurable keys.
  • I also provide: the settings key-value store and the audit_log, both owned here.
  • I depend on shared:

Mirror note

Every "I receive from Accounts: user id + role" line in News, Legal, and Courses must mirror the "I provide to" line above. If a name changes here, those modules change with it — through ../../shared/change-requests.md, never by editing shared files directly.

3.12modules/accounts-admin/open-questions.md

Accounts & Admin — Open questions (TODO)

← overview.md

Most earlier questions are now decided (see ../../_decisions.md): password rule, token expiries, remember-me, self-deactivation, who creates consultant/admin accounts, email re-verification. What remains:

  • TODO: Audit log retention. How long do we keep audit_log rows — forever, or prune after a

period? Decide before the table grows large.

  • TODO: Settings value typing. settings.value is stored as text. Confirm the per-key parsing

(integer for legal.max_questions_per_day, text for legal.disclaimer_text, etc.) is documented wherever each key is read.

4Module — Romanian Courses

4.1modules/courses/README.md

Module: Romanian Courses — Start Guide

← Landaro · Shared

Owner: _(write your name here when you take this module)_

Before you write anything, read the main README and the whole shared/ folder. Then write your documentation in your documentation as a set of files (see overview.md as the entry point), using the structure from doc-template.md.

What this module is about

Free Romanian language courses for logged-in users. Access is free for 10 days. When the free period ends, the user is invited to bring their company for a talk with the stakeholders (a "company lead") instead of continuing for free.

In scope

  • A list of courses and their lessons.
  • A lesson page (text/video/exercise — your call, document it).
  • Tracking the user's free period (uses users.free_period_ends_at).
  • The "free period ended" state and the company-lead invitation screen.
  • Simple progress tracking (which lessons a user finished).

Out of scope (don't document these here)

  • Registration / login → Accounts & Admin module.
  • Payments / billing → not in phase 1 (after the free period we invite to a talk, we

don't charge online). If you think we need it, raise it as an open question.

  • News / legal → their own modules.

What you depend on (shared)

  • users.id, role, and especially users.free_period_ends_at from

data-model.md.

  • Only a logged-in user can access courses — see roles.md.
  • API envelope + errors from api-contract.md.
  • Your own tables (e.g. courses, lessons, lesson_progress) are module-specific.

Questions to answer while documenting

  • When does the 10-day clock start? (at registration? at first course open?)
  • What exactly happens on day 11? (locked content + company-lead screen)
  • What does the company-lead invitation ask for / collect?
  • How is progress stored and shown?
  • Can an admin extend or reset a user's free period? (if yes, that's an admin feature)

Anti-conflict reminder

free_period_ends_at lives on the shared users table (owned by Accounts). You read it. If you need a new user field, request it in change-requests.md — don't edit shared files.

4.2modules/courses/overview.md

Romanian Courses — Overview

← Landaro · Shared

Owner: Hansa Module: courses Last updated: 2026-10-12

Romanian Courses gives a logged-in newcomer free Romanian lessons to start breaking the language barrier. A course is organized as modules → lessons, the user enrolls per module, and finishes a module by completing its lessons and passing its tests (earning a certificate). The first 10 days are free (global per account); after that lessons lock and a lead modal invites the user to bring their company in — see free-period.md and company-lead.md.

Documentation in this module

FileWhat it covers
purpose.mdWhat the module solves and why it matters
users-and-roles.mdWho can access courses and what each does
features.mdThe list of things the module can do
modules-and-tests.mdCourse→modules→lessons, enroll, tests, completion
free-period.mdThe global 10-day free window and what happens at expiry
company-lead.mdThe post-expiry lead modal and full-access grant
certificates.mdWhat a completed module issues
flows.mdEnroll, learn, test, hit the lock, submit a lead
screens.mdThe pages and what each shows / does
data-model.mdAll the module's tables
business-rules.mdStructure, tests, lock, and access rules
messages.mdSuccess and error messages
dependencies.mdWhat this module gives to and takes from others
open-questions.mdDecisions still open, marked as TODO

Reading order

purpose.md → free-period.md → flows.md → data-model.md, then the rest as needed.

4.3modules/courses/purpose.md

Romanian Courses — Purpose

← overview.md

What this module solves

The single biggest barrier for a non-EU newcomer in Romania is the language. Not speaking Romanian makes everything harder — paperwork, work, daily life. Courses gives the newcomer a free, structured way to start learning from day one, right inside Landaro.

How it works

  • A visitor who registers and becomes a user gets access to the Romanian course.
  • The first 10 days are free — full access to the free lessons.
  • After the free period the lessons lock, and the user is invited to bring their company into

a conversation with the Landaro team to continue (the "company lead").

Who it is for

  • The newcomer (user) who wants to start learning Romanian immediately, at no cost, during

their first days in the country.

  • The admin who manages the courses, lessons, and their content.

Why it matters

A free trial lowers the barrier to even start learning, which is exactly when a newcomer is most overwhelmed. The 10-day window is long enough to prove value and build a habit, and the company-lead step turns engaged learners into a real conversation with the business — without abruptly cutting off someone who's just getting going.

In one line

Courses gets the newcomer speaking a bit of Romanian for free in their first 10 days, then opens a path to continue through their company.

4.4modules/courses/users-and-roles.md

Romanian Courses — Users & roles

← overview.md

Courses uses the platform roles from ../../shared/roles.md and reads the current user's id, role, and users.free_period_ends_at from Accounts & Admin (see dependencies.md).

Who does what

  • Visitor — Cannot open lessons, but can see that a Romanian course exists (a teaser that

encourages registration).

  • User — Enrolls in modules, works through lessons, takes tests, and earns certificates

during the free period or with has_full_access. After expiry (no full access) lessons lock, progress goes read-only, and the lead modal appears on every navigation.

  • Admin — Manages courses, modules, lessons (incl. video), and tests; sees all users; extends

a user's free period (logged); and reviews company leads, marking them valid (grants full access) or invalid.

Boundaries

  • Course content is for logged-in users only; a visitor sees the shell, not the lessons.
  • A user's access is governed by their own free_period_ends_at / has_full_access, never

another user's.

  • Admin manages content and can extend a user's free period, but the clock itself starts when the

user starts their first lesson (see free-period.md). users is owned by Accounts; Courses triggers the writes, Accounts performs them.

4.5modules/courses/features.md

Romanian Courses — Features

← overview.md

Learning

  • Course → modules → lessons — Browse a course, see its modules, and enroll in a module.
  • Enroll per module — An explicit "enroll" action at the module level.
  • Lesson pages — Ordered lessons with text/HTML + images and an optional **YouTube video via

a custom player**. Each lesson has a "start" action.

  • Progress tracking — Per-lesson progress, so the user resumes where they left off.

Tests & completion

  • Module tests — Multiple-choice (single correct answer), set in admin, pass mark ≥ 75%,

unlimited attempts with full history. See modules-and-tests.md.

  • Module completion — All lessons completed + all tests passed ⇒ module completed, with a

visual "Completed" check.

  • Certificates — On completion: a certificate on the profile with PDF download, plus an email

link. See certificates.md.

Free period & access

  • Free-period tracking — A global 10-day window from the first lesson started; shows the

countdown. See free-period.md.

  • Lock after expiry — Lessons lock; progress stays read-only.
  • Lead modal — After expiry, a modal appears on every navigation until the user submits a

company lead. See company-lead.md.

Admin

  • Manage content — Create/edit courses, modules, lessons (incl. video URL), and tests.
  • See all users & extend free period — View users and grant extra free days (logged).
  • Leads — See submitted leads, be emailed on a new one, and mark each valid / invalid;

valid grants the user full access.

4.6modules/courses/flows.md

Romanian Courses — Flows

← overview.md

Field names and rules match modules-and-tests.md, free-period.md, and data-model.md.

1. Enroll and start learning

  1. A logged-in user browses a course and its modules and clicks enroll on a module.
  2. The user opens a lesson and clicks "start". If it's the first lesson started on the account,

Courses triggers Accounts to set free_period_ends_at = now + free_period_days (10).

  1. The user works through the lessons; progress is saved per lesson

(not_started → in_progress → completed).

2. Take the test and complete the module

  1. After all lessons in the module are completed, the module's test(s) unlock.
  2. The user takes a test (multiple-choice, single answer). The attempt is saved with its score

and attempt_number.

  1. If the score is ≥ 75% the attempt passes; otherwise the user can retake (unlimited attempts).
  2. When all lessons are completed and all tests passed, the enrollment becomes completed, a

"Completed" check shows, and a certificate is issued (profile PDF + email link).

3. Hit the lock (free period expired)

  1. The user returns after free_period_ends_at has passed and has_full_access is false.
  2. Lesson content locks; progress stays read-only.
  3. The lead modal appears on every navigation until the user submits a company lead.

4. Submit a company lead

  1. In the modal the user writes their company details (a textarea) and submits.
  2. The system stores/updates the single company_leads row for that user (status = new), and

the modal stops appearing.

  1. The admin is emailed and sees the lead in the panel; marking it valid grants the user

has_full_access (unlimited access, no more modal). invalid changes nothing.

5. Admin extends the free period

  1. From a user's detail screen, the admin sets a new, later free_period_ends_at.
  2. The change is logged (who, when, how many days).
4.7modules/courses/screens.md

Romanian Courses — Screens / pages

← overview.md

1. Course & module list

  • Shows: The course with its modules; a visitor sees a teaser that the course exists.
  • Does: Lets a logged-in user enroll in a module and open it. A visitor is prompted to log in.

2. Lesson page

  • Shows: Lesson content (text/HTML + images), an optional video in the custom player, the

lesson's position, and a "start" action on an unstarted lesson.

  • Does: Lets the user start and work through a lesson. Starting the first lesson begins the

global free-period clock.

3. Test page

  • Shows: The module's multiple-choice questions (unlocked after lessons are completed).
  • Does: Lets the user submit answers; shows the score and pass/fail; allows retakes

(unlimited). Each attempt is recorded.

4. Progress view

  • Shows: Lesson and module progress, test attempt history, and the free-period countdown.
  • Does: Lets the user resume and see completion status. Read-only once the period expires.

5. Lead modal (after expiry)

  • Shows: A friendly-but-persistent textarea asking for company details, on every navigation.
  • Does: Submits/updates the user's single company_leads record; disappears once submitted.

6. Admin — content management

  • Shows: Courses, modules, lessons (incl. video URL), and tests with their questions/options.
  • Does: Lets admin create/edit all course content and set test questions. Admin-only.

7. Admin — users & leads

  • Shows: All users with their free-period status, plus the submitted company leads.
  • Does: Lets admin extend a user's free period (logged) and mark a lead valid / invalid

(valid grants full access). Admin-only.

Note on the certificate

A completed module shows a "Completed" check and produces a certificate available on the user's profile (PDF download) and by email — see certificates.md.

4.8modules/courses/business-rules.md

Romanian Courses — Business rules

← overview.md

Structure & enrollment

  • Hierarchy is course → modules → lessons. A user enrolls at the module level (explicit

enroll).

  • A module is completed only when all its lessons are completed AND **all its tests are

passed**.

Tests

  • Multiple-choice, single correct answer per question; questions set in admin.
  • Pass mark is ≥ 75% (courses.test_pass_threshold, a setting).
  • Unlimited attempts, each recorded in test_attempts with its score and attempt_number.
  • A module's test(s) unlock only after the module's lessons are completed.

Certificates

  • Issued once per user per completed module.
  • Contains name, module, completion date, unique verification ID, the passing attempt number, and

Landaro branding. Available as profile PDF and by email. See certificates.md.

Free period & access

  • The free period is global per account: 10 days (courses.free_period_days) from the first

lesson started anywhere. The clock starts on first lesson start, not at registration or login.

  • When Courses detects the first lesson start, it triggers Accounts to set

free_period_ends_at.

  • After expiry (and has_full_access false): lessons lock, progress is read-only, and the lead

modal appears on every navigation until a lead is submitted.

  • Access = valid free period OR has_full_access. A lead marked valid by an admin sets

has_full_access = true (unlimited access everywhere, no modal). An admin may also extend free_period_ends_at (logged).

Content

  • Lessons are text/HTML + images, plus an optional YouTube video shown via a custom player;

videos are private and not meant to be reached directly on YouTube.

Company lead

  • One company_leads row per user (upsert); the modal is a single free-text textarea.
  • Admin is emailed on a new lead and marks it new / valid / invalid; marking is logged.
4.9modules/courses/data-model.md

Romanian Courses — Data model

← overview.md

Hierarchy: course → modules → lessons. Users enroll at the module level. The module owns the tables below and reads the access state from the shared users table (read-only).

courses

A top-level course (e.g. "Romanian"). An umbrella for its modules.

FieldTypeNotes
idintegerprimary key
titlestringcourse title
slugstringunique URL-friendly identifier
descriptiontextwhat the course covers (nullable)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

modules

A module inside a course. The unit a user enrolls in and completes.

FieldTypeNotes
idintegerprimary key
course_idintegerreferences courses.id
titlestringmodule title
positionintegerorder within the course
descriptiontextwhat the module covers (nullable)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

lessons

A lesson inside a module. Content is text/HTML + images, and optionally a YouTube video shown via a custom player.

FieldTypeNotes
idintegerprimary key
module_idintegerreferences modules.id
titlestringlesson title
positionintegerorder within the module
contenttextlesson body (text/HTML + images)
video_urlstringoptional YouTube URL, played via the custom player (nullable)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

enrollments

A user's explicit enrollment in a module.

FieldTypeNotes
idintegerprimary key
user_idintegerreferences users.id
module_idintegerreferences modules.id
statusstringin_progress or completed
completed_atdatetimewhen the module was completed (nullable)
created_atdatetimetimestamp in UTC

lesson_progress

Per-lesson progress. The first row's started_at across the account marks when the global free-period clock began (see free-period.md).

FieldTypeNotes
idintegerprimary key
user_idintegerreferences users.id
lesson_idintegerreferences lessons.id
statusstringnot_started, in_progress, completed
started_atdatetimewhen the user first opened the lesson (nullable)
completed_atdatetimewhen the user finished it (nullable)

Tests — see modules-and-tests.md for the rules

tests

A module's test. Multiple-choice, single correct answer per question.

FieldTypeNotes
idintegerprimary key
module_idintegerreferences modules.id
titlestringtest title
pass_thresholdintegerpercent needed to pass; default from settings (75)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

test_questions

A question in a test.

FieldTypeNotes
idintegerprimary key
test_idintegerreferences tests.id
bodytextthe question text
positionintegerorder within the test

test_options

An answer option; exactly one per question is correct.

FieldTypeNotes
idintegerprimary key
question_idintegerreferences test_questions.id
bodystringthe option text
is_correctbooleantrue for the single correct option

test_attempts

Every attempt a user makes (full history, unlimited attempts).

FieldTypeNotes
idintegerprimary key
user_idintegerreferences users.id
test_idintegerreferences tests.id
scoreintegerpercent scored on this attempt
passedbooleanscore ≥ pass_threshold
attempt_numberinteger1-based attempt counter for this user+test
created_atdatetimetimestamp in UTC

certificates

Issued when a user completes a module — see certificates.md.

FieldTypeNotes
idintegerprimary key
user_idintegerreferences users.id
module_idintegerreferences modules.id
verification_idstringunique public verification code
passing_attempt_numberintegerwhich test attempt achieved the pass
issued_atdatetimetimestamp in UTC

company_leads

Captured when a user submits the lead modal after the free period ends. One lead per user (upsert).

FieldTypeNotes
idintegerprimary key
user_idintegerreferences users.id (unique — one lead per user)
bodytextfree-text message (the modal is a textarea)
statusstringnew, valid, invalid (set by admin)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

Shared tables (referenced, read-only)

  • users — read role, free_period_ends_at, and has_full_access to decide locked vs.

unlocked and whether to show the lead modal. Courses triggers Accounts to set free_period_ends_at on the first lesson start, but never writes users directly.

Settings used

  • courses.free_period_days (default 10) and courses.test_pass_threshold (default 75) live in

the shared settings table (owned by Accounts & Admin).

4.10modules/courses/messages.md

Romanian Courses — Messages & errors

← overview.md

Errors

  • "Please log in to access courses." — A visitor tries to open a lesson.
  • "Your free period has ended." — The 10-day window passed; lessons are locked. Leads to the

lead modal.

  • "Complete the lessons before taking the test." — The user opens a test before finishing the

module's lessons.

  • "You didn't reach the pass mark — try again." — A test attempt scored below 75%.
  • "Lesson not found." — A search or link points to a lesson that doesn't exist.

Success

  • "Enrolled in the module." — The user enrolled in a module.
  • "Lesson started." — The user began a lesson (first lesson also starts the free-period clock).
  • "Lesson completed." — Progress saved.
  • "Test passed — module completed!" — All lessons done and the test passed; certificate issued.
  • "Your certificate is ready." — The certificate is available on the profile and sent by email.
  • "Company details submitted." — The lead was saved; the modal will stop appearing.

Admin

  • "Free period extended." — The admin granted extra days (logged).
  • "Lead marked valid — full access granted." / "Lead marked invalid." — The admin

reviewed a lead.

4.11modules/courses/dependencies.md

Romanian Courses — Dependencies

← overview.md

  • I receive from: Accounts & Admin — the logged-in user's id and role, and the access state

(free_period_ends_at, has_full_access), to decide locked vs. unlocked and whether to show the lead modal.

  • I provide to: Accounts & Admin — a trigger to set free_period_ends_at when the user starts

their first lesson, and a request to set has_full_access = true when an admin marks a lead valid. No other module reads Courses' data.

  • I depend on shared:

Mirror note

My "I receive from Accounts: user id + role + access state" must mirror the "I provide to Courses" line in Accounts & Admin, including the trigger that Accounts writes free_period_ends_at/has_full_access on my behalf. Anything new on a shared table goes through ../../shared/change-requests.md, never a direct edit.

4.12modules/courses/modules-and-tests.md

Romanian Courses — Modules & tests

← overview.md

How the course is structured and how a user completes a module.

Hierarchy

  • Course (e.g. "Romanian") is an umbrella. It contains modules.
  • A module contains ordered lessons and one or more tests.
  • A user enrolls in a module explicitly (an "enroll" action), separate from simply opening a

lesson.

Lessons

  • Content is text/HTML + images, and optionally a YouTube video played through a **custom

player** so the raw YouTube page isn't trivially reachable (videos are private).

  • A lesson is tracked per user in lesson_progress: not_started → in_progress →

completed.

  • Starting the very first lesson on the account starts the global free-period clock (see

free-period.md).

Tests

  • A test is multiple-choice with a single correct answer per question. Admins set the

questions and options in the admin panel.

  • The pass mark is ≥ 75% (courses.test_pass_threshold, a setting).
  • Unlimited attempts. Every attempt is stored in test_attempts with its score and

attempt_number; the full history is kept for statistics and for the certificate.

  • A module's test(s) unlock after the module's lessons are completed.

What "module completed" means

A module is completed when all its lessons are completed AND all its tests are passed (an attempt scored ≥ the pass threshold). On completion:

  • the user gets a visual "Completed" check,
  • a certificate is issued (see certificates.md),
  • the enrollment's status becomes completed with completed_at set.
4.13modules/courses/certificates.md

Romanian Courses — Certificates

← overview.md

When a user completes a module (all lessons completed and all tests passed), Landaro issues a certificate for that module.

What the user gets

  • A visual "Completed" check next to the module (and in their profile).
  • A certificate on their profile page, with a download PDF button.
  • An email with a link to the certificate (which can be exported as PDF).

What the certificate contains

  • The user's name.
  • The module name.
  • The completion date.
  • A unique verification ID (certificates.verification_id).
  • The attempt number on which the test was passed (passing_attempt_number).
  • The Landaro logo / name.

Rules

  • One certificate per user per completed module (certificates row).
  • The verification ID is unique and can be used to confirm a certificate is genuine.
  • The certificate is generated at the moment the module is completed.
4.14modules/courses/free-period.md

Romanian Courses — The free period

← overview.md

The 10-day free window is the defining mechanic of this module. Getting the start moment right matters, because it decides when the clock runs out.

When the clock starts

The countdown starts when the user starts their first lesson — not before. Concretely:

  • Not when the user registers.
  • Not when the user logs in.
  • Not when the user merely reaches or opens the course list.
  • Only when the user clicks the "start" action on their first lesson. Lessons have an

explicit start button so this moment is unambiguous.

Once started, the window runs for exactly 10 days from that moment and does not pause.

The window is global per account — 10 days from the first lesson started anywhere, not per module. (courses.free_period_days is a setting, default 10.)

What happens when it expires

  • The lessons lock: the user can no longer open lesson content. Their **progress stays

visible, read-only**.

  • A lead modal appears on every navigation until the user submits a company lead — forceful

but friendly. (See company-lead.md.)

  • Everything that is gated across the platform is affected the same way: besides Course lessons,

the Legal "ask a consultant" chat is also cut off. News and the Legal FAQ stay available.

How a user regains access

  • An admin can extend the free period (push free_period_ends_at forward); logged for audit.
  • A company lead marked valid by an admin sets has_full_access = true → unlimited access

to everything, and the modal never shows again.

How it's stored

  • The start moment is captured on the user's first lesson_progress row (started_at).
  • When that first lesson starts, Courses triggers Accounts to set

users.free_period_ends_at = now + free_period_days.

  • users.free_period_ends_at and users.has_full_access (both owned by Accounts & Admin) decide

locked vs. unlocked. Courses reads them and never writes to users directly.

See the exact rules in business-rules.md and the fields in data-model.md.
4.15modules/courses/company-lead.md

Romanian Courses — Company lead

← overview.md

After the free period expires (and the user does not have has_full_access), the user is invited — persistently — to submit a company lead so the Landaro team can get in touch with their company and open up full access.

The modal

  • Appears on every navigation once the free period has expired, until a lead is submitted.
  • Intentionally hard to ignore, but friendly in tone. The user can keep browsing (News, FAQ,

read-only progress), but the modal keeps returning.

  • It is a simple textarea where the user describes their company / leaves their details.
  • Once a lead is submitted, the modal stops appearing.

One lead per user

  • There is one company_leads row per user (upsert): submitting again updates the same

record rather than creating duplicates. The content is free text, so a user can write as much as they like in that single textarea.

What the admin does

  • The admin sees the lead in the panel and is emailed when a new lead arrives.
  • The admin marks the lead valid or invalid:
    • valid → the user's has_full_access becomes true: unlimited access to courses,

consultancy, and news, and the modal never shows again.

    • invalid → no access change (the user stays gated and the modal keeps appearing).
  • Marking a lead is recorded in the audit log.
has_full_access lives on the shared users table (owned by Accounts & Admin); Courses triggers the change but Accounts writes the field.
4.16modules/courses/open-questions.md

Romanian Courses — Open questions (TODO)

← overview.md

Most earlier questions are decided (see ../../_decisions.md): the course→modules→lessons hierarchy, enroll per module, free period global per account, tests (multiple-choice, ≥75%, unlimited attempts with history), certificates, the lead modal, admin extend days, full access on valid lead, and video via custom YouTube player. What remains:

  • TODO: Free period scope — confirm. It's global per account for now; you flagged it may

change to per-module later. Flag before build if it changes.

  • TODO: One test per module or several? tests allows multiple per module. Confirm whether a

module ships with exactly one test or can have several (affects the "all tests passed" rule).

  • TODO: Custom video player scope. What exactly should the custom player hide/disable (title,

YouTube logo, right-click, related videos)? Define the minimum for v1.

  • TODO: Certificate verification page. The certificate has a verification ID — do we need a

public page to look it up, or is the ID just printed for now?

  • TODO: Re-enrollment. Can a user re-enroll in / retake a module already completed, and does

that affect the existing certificate?

5Module — Legal Guidance

6Module — News

6.1modules/news/README.md

Module: News — Start Guide

← Landaro · Shared

Owner: _(GB)_

Before you write anything, read the main README and the whole shared/ folder. Then write your documentation in your documentation as a set of files (see overview.md as the entry point), using the structure from doc-template.md.

What this module is about

Useful news for non-EU people coming to Romania: changes in rules, deadlines, practical announcements. A visitor can read; an admin writes and publishes.

In scope

  • A list of articles (newest first), with categories.
  • A single article page.
  • Admin: create / edit / publish / unpublish / delete an article.
  • Simple search or filter by category.

Two kinds of article (important)

Every article is one of two types, and the module has to handle both:

  • Curated / aggregated — we pick up a piece of news from an external source and

republish only a short extract. There is a hard limit on how much we show (a maximum number of characters or words — decide and document the exact number), and the source must always be credited: the original publisher's name and a link back to the full article. We never reproduce the whole thing.

  • Original — we write the full article ourselves. No external source needed; it's

our own content from start to finish. Document how the two differ in the data (e.g. a type field, plus source_name and source_url that are required for curated and empty for original), how they look on the article page (a curated item shows a "Source: …" credit and a "read the full story" link), and the rule that enforces the length limit on curated items.

Out of scope (don't document these here)

  • Login and accounts → that's the Accounts & Admin module.
  • Legal advice content → that's the Legal module.
  • Courses → the Courses module.

What you depend on (shared)

  • users.id + role to know who may publish (admin only). See

roles.md.

  • The API envelope and error shape from api-contract.md.
  • Your own tables (e.g. articles, categories) are module-specific — document them in

data-model.md, and reference users.id for the author.

Questions to answer while documenting

  • What fields does an article have? (title, body, category, status, published date, author)
  • Is the article curated or original? (the type field)
  • For a curated article: what is the exact limit we show (characters or words, and how

many), and what source fields are required? (source_name, source_url)

  • What validation enforces that limit, and what happens if an admin pastes more than

allowed? (write it as a business rule + an error message)

  • What states can an article be in? (draft / published)
  • Who sees drafts? (admin only)
  • Can an article belong to more than one category?
  • How is the list paginated? (reuse meta from the API contract)

Anti-conflict reminder

Work only in this folder. If you need a shared change (e.g. a new field on users), request it in change-requests.md — don't edit shared files.

6.2modules/news/overview.md

News — Overview

← Landaro · Shared

Owner: GB Module: news Last updated: 2026-10-12

News keeps non-EU newcomers up to date with rule changes, deadlines, and practical announcements. A visitor reads; an admin writes and publishes. Every article is one of two types — curated (a short, credited extract from an external source) or original (written in-house) — and both share the same categories and tags.

Documentation in this module

FileWhat it covers
purpose.mdWhat the module solves and why it matters
users-and-roles.mdWho uses News and what each may do
features.mdThe list of things the module can do
article-types.mdCurated vs. original: the core distinction
flows.mdStep-by-step create, publish, and read paths
screens.mdThe pages and what each shows / does
data-model.mdThe categories, articles, tags, article_tags tables
business-rules.mdPublishing, sourcing, and trim rules
messages.mdSuccess and error messages
dependencies.mdWhat this module gives to and takes from others
open-questions.mdDecisions still open, marked as TODO

Reading order

purpose.md → article-types.md → data-model.md → flows.md, then the rest as needed.

6.3modules/news/purpose.md

News — Purpose

← overview.md

What this module solves

Immigration and legal rules in Romania change often, and the official sources are scattered, dense, and rarely written for someone who just arrived. News turns that noise into a clear, dated feed a newcomer can actually follow: what changed, when it takes effect, and what to do about it.

Who it is for

  • The newcomer (visitor or user). Someone trying to keep their stay legal who needs to

know about a new deadline, a changed visa rule, or a practical announcement — without digging through government portals.

  • The admin. Staff who publish updates, either by writing an original piece or by

curating a short, credited extract from an official or reputable source.

Why it matters

A missed deadline can cost a newcomer their legal status. By surfacing the right update at the right time, in plain language and sorted newest-first, News directly protects the user's stay and reduces the anxiety of not knowing. The two-type model (curated vs. original) lets the team move fast — republish a short extract with full credit when speed matters, or write a thorough original guide when depth matters — without ever reproducing someone else's full article.

In one line

News is the newcomer's early-warning feed for anything that affects their life and legal status in Romania.

6.4modules/news/users-and-roles.md

News — Users & roles

← overview.md

News uses the platform roles defined in ../../shared/roles.md. It does not define its own; it reads the current user's id and role from Accounts & Admin (see dependencies.md).

Who does what

  • Visitor — Reads the public feed and any published article. Cannot see drafts and cannot

reach the admin editor.

  • User — Same reading rights as a visitor. Being logged in does not add News powers; News

is about reading for everyone except admins.

  • Consultant — No special News role. A consultant reads News like any other logged-in

person. News management is not their job.

  • Admin — The only role that can create, edit, publish, unpublish, and delete articles,

and the only one who can see drafts.

The split that matters

Everything in News reduces to one line: everyone reads published content, only admins write and manage it. Draft and unpublished content is visible to admins only. There is no in-between "editor" role in this module — publishing power belongs to admin.

6.5modules/news/features.md

News — Features

← overview.md

Reading (public)

  • News feed — All published stories in a clean list, newest first, with title, short

summary, category, tags, date, and feature image.

  • Single article page — The full story (or the trimmed extract, for curated items), with

category, tags, date, and related articles. Curated items also show a source credit and a link back to the original.

  • Filter & search — Narrow the feed by category or by tag (visas, legal changes, housing,

…), or run a plain text search to jump straight to a topic.

Writing & managing (admin)

  • Admin article editor — Create and edit articles of either type. Picking curated

reveals the required source fields; picking original hides them.

  • Draft / publish lifecycle — Save as draft, publish, unpublish, or delete. Only published

articles reach the public.

  • Categories & tags — Organize both article types with one category per article and any

number of tags.

Type handling (the News-specific part)

  • Curated import with auto-trim — Paste an extract from an external source; the system

trims it to the allowed length and requires a source name and link. See article-types.md.

  • Original authoring — Write the full piece in-house, like a blog post, with no external

source.

6.6modules/news/flows.md

News — Flows

← overview.md

Field names and the two types match article-types.md and data-model.md.

1. Create an original article

  1. Admin opens the editor and picks type = original.
  2. Admin writes the full title and body, assigns one category, and adds any tags.
  3. The source fields stay hidden and empty.
  4. Admin saves as a draft.

2. Create a curated article

  1. Admin opens the editor and picks type = curated.
  2. The source_name and source_url fields appear and are required.
  3. Admin pastes the extract and fills in the source name and link.
  4. On save, the system trims the extract to the allowed length and refuses to save if either

source field is missing or the link is invalid.

  1. Admin assigns one category and any tags, and saves as a draft.

3. Publish

  1. Admin reviews the draft.
  2. Admin clicks Publish; status becomes published and published_at is set.
  3. The article now appears in the public feed (once published_at is at or before now).

4. Read (visitor)

  1. Visitor opens the news page and sees published articles, newest first.
  2. Visitor filters by category or tag, or searches.
  3. Opening a curated article shows the extract plus the source credit and the link back;

opening an original article shows the full in-house body.

6.7modules/news/screens.md

News — Screens / pages

← overview.md

1. News list page

  • Shows: A feed of article cards — title, short summary, category, tags, publication date,

and feature image — newest first.

  • Does: Lets visitors scroll, open an article, filter by category or tag, and search by

keyword.

2. Single article page

  • Shows: Headline, body, date, category, tags, and related articles. An original

article shows the full in-house body. A curated article shows the trimmed extract, followed by a "Source: {source_name}" credit and a "read the full story" link to source_url.

  • Does: Lets the reader read the update, follow the source link on curated items, share

the story, and return to the feed.

3. Admin article editor

  • Shows: A type selector (curated / original), title field, rich-text content editor,

category dropdown, tags input, feature image upload, status (Draft / Published), and publishing date. When type = curated, the required source_name and source_url fields appear.

  • Does: Lets an admin write an original article or paste a curated extract (auto-trimmed),

edit, save drafts, publish, schedule, unpublish, or delete.

6.8modules/news/business-rules.md

News — Business rules

← overview.md

Publishing & visibility

  • Only articles with status = 'published' appear on the public feed and article pages.

Drafts and archived posts are hidden from everyone except admins.

  • An article is only visible if status = 'published' and published_at is at or before

the current UTC time (so future-dated posts stay hidden until their time).

  • Only admins can view drafts, create, edit, change status, or delete articles.

Content integrity

  • Every article needs a non-empty title, body, and exactly one category before it can be saved

or published.

  • Every article and category must have a unique slug, so URLs never collide.
  • An article has exactly one category and zero or more tags.

Article types (see article-types.md)

  • Every article has a type of either curated or original; there is no third option.
  • Curated must credit the source. source_name and source_url are both required when

type = 'curated'; the article cannot be saved or published without them.

  • Curated is trimmed. On save, a curated content is automatically trimmed to a maximum

of 50 words, cut at the last whole word with an ellipsis "…" appended. The full external text is never stored or shown — only the extract plus the link back.

  • Original carries no source. When type = 'original', source_name and source_url

are empty and the body is not trimmed.

Images

  • A feature image is optional for both types; when absent, a placeholder image is shown.
6.9modules/news/data-model.md

News — Data model

← overview.md

The module owns four tables and references users.id as the author. It never alters the shared users schema.

categories

News categories (e.g., Visas, Work Permits, Housing, Legal Updates). An article belongs to exactly one category.

FieldTypeNotes
idintegerprimary key
namestringunique category name
slugstringURL-friendly identifier
descriptiontextbrief summary of the category (nullable)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

articles

All published and draft content. type decides curated vs. original; source_name and source_url are required when type = 'curated' and empty when type = 'original'.

FieldTypeNotes
idintegerprimary key
category_idintegerreferences categories.id
author_idintegerreferences users.id (admin/author)
typestringcurated or original
titlestringarticle title
slugstringunique URL-friendly title
contenttextbody; for curated items this is the trimmed extract
source_namestringpublisher name — required for curated, empty for original (nullable)
source_urlstringlink to the original — required for curated, empty for original (nullable)
image_pathstringoptional feature image; a placeholder is shown when empty (nullable)
statusstringdraft or published
published_atdatetimepublication date in UTC (nullable)
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

tags

Free-form tags that apply to both article types (e.g., visa, deadline, housing).

FieldTypeNotes
idintegerprimary key
namestringunique tag name
slugstringURL-friendly identifier
created_atdatetimetimestamp in UTC
updated_atdatetimetimestamp in UTC

article_tags

Join table linking articles to tags (many-to-many).

FieldTypeNotes
article_idintegerreferences articles.id
tag_idintegerreferences tags.id
The trim limit (50 words) is a business rule, not a column — see business-rules.md and article-types.md.

Shared schema change requests

#DateRequested byModuleWhat / whyStatus
12026-10-08Gyanendra BohoraNewsAdd is_featured (boolean) to articles to pin urgent legal news to the top of the feed.open
6.10modules/news/messages.md

News — Messages & errors

← overview.md

Validation errors

  • "Title is required." — Saving/publishing without a title.
  • "Content cannot be empty." — Saving an article with no body.
  • "Please select a category." — No category chosen before publishing.
  • "A category with this name already exists." — Duplicate category name.
  • "A curated article must credit its source." — type = 'curated' saved without

source_name or source_url.

  • "Please enter a valid source link." — source_url is not a valid URL on a curated item.
  • "The extract was shortened to 50 words." — Informational notice when a pasted

curated extract exceeds the limit and is auto-trimmed.

System & navigation errors

  • "Article not found." — Invalid link, or an attempt to open a draft/deleted article.
  • "You are not allowed to access this section." — A non-admin opens the editor or admin panel.
  • "You are not allowed to publish articles." — Missing permission to set status to published.

Success messages

  • "Article saved as draft." — A draft was stored.
  • "Article published successfully." — The post is live.
  • "Category created successfully." — A new category was added.
  • "Article deleted." — An admin removed an article.
6.11modules/news/dependencies.md

News — Dependencies

← overview.md

  • I receive from: Accounts & Admin — the logged-in user's id and role, so I know whether

the person is a visitor (read only) or an admin (create/edit/publish). News does not do its own login.

  • I provide to: the public site / homepage — a list of the latest published articles. Not

applicable for other modules: no other Landaro module reads News data.

Mirror note

My "I receive from Accounts: user id + role" must mirror the "I provide to News" line in Accounts & Admin. Anything new on a shared table goes through ../../shared/change-requests.md, not a direct edit.

6.12modules/news/article-types.md

News — Article types

← overview.md

Every article is exactly one type: curated or original. This is the defining distinction of the module and drives the data, the editor, the public page, and the rules.

Curated (aggregated)

We pick up a piece of news from an external source and republish only a short extract, never the whole thing. The admin pastes the article into a dedicated field and fills in the source link.

  • The imported text is automatically trimmed to a maximum of 50 words on save, cut at the

last whole word with an ellipsis "…" appended.

  • The source must always be credited: source_name and source_url are both required.
  • On the public page the extract is followed by a "Source: {source_name}" credit and a

"read the full story" link pointing to source_url.

Original

We write the full article ourselves, start to finish, like a blog post.

  • No external source: source_name and source_url are empty.
  • The body is the complete in-house content and is not trimmed.

Side by side

AspectCuratedOriginal
Where content comes fromexternal source, short extractwritten in-house, full text
source_name / source_urlrequiredempty
Length limittrimmed to 50 words + "…"none
Public page showsextract + "Source: …" + link backfull body
Categories & tagsyesyes
Feature imageoptional (placeholder if absent)optional (placeholder if absent)

Both types are stored in the same articles table, distinguished by the type field — see data-model.md. The rules that enforce all of the above live in business-rules.md.

6.13modules/news/open-questions.md

News — Open questions (TODO)

← overview.md

  • TODO: Multilingual article content. The UI is EN/FR, but article bodies — single language

per article, or translated versions? (UI labels are handled by the i18n JSON files; this is about the article content itself.)

  • TODO: Expiration & archiving. Do legal news items expire automatically, or does an admin

archive them manually? Recommendation: manual archiving, so long-term guides don't vanish.

  • TODO: Featured / pinned articles. Add an is_featured flag to keep urgent alerts at the

top? Recommendation: yes — already raised as a change request in data-model.md.

  • TODO: Notifications. Should publishing an urgent alert email/notify registered users?

Recommendation: an optional "Notify users" checkbox in the editor for breaking updates.

  • TODO: File attachments. Can admins attach downloadable PDFs (official forms, decrees) to a

post? Recommendation: allow uploads in the rich-text editor.

7Build & Infrastructure

7.1_build/README.md

Landaro — Build (code)

How to run the implemented application. This folder documents the code; the feature docs live in ../modules/ and ../shared/.

Status

  • Milestone 1 — Accounts & Admin: DONE (backend + frontend, verified end to end).
  • Milestone 2 — News: DONE (backend + frontend, verified end to end).
  • Milestone 3 — Legal: DONE (backend + frontend, verified end to end).
  • Milestone 4 — Courses: DONE (backend + frontend, verified end to end).

All four modules complete.

Layout

  • ../backend/ — PHP 8.3 REST API (plain OOP, no framework, no Composer).
  • ../frontend/ — Angular 20 app (standalone components).
  • ../database/schema.sql — full MySQL 8 schema (all modules).

Prerequisites (your MAMP)

  • PHP 8.3 (/Applications/MAMP/bin/php/php8.3.30/bin/php).
  • Apache on :8888, MySQL 8 on :8889 (user/pass root/root).
  • Node 22.20 + npm (already present).

One-time setup

See setup.md.

Run it

See run.md.

What is implemented

See implemented.md.

Config & secrets

  • Real config: ../backend/config/config.php (git-ignored).
  • Fill the mail block with your SMTP credentials. Until then, emails are written

to ../backend/storage/mail/*.html so activation links are still reachable.

7.2_build/courses.md

Landaro — Courses (Milestone 4)

← build README

Course → modules → lessons; enroll per module; free period; tests; certificates; lead.

Endpoints

MethodPathRolePurpose
GET/courses/catalogpubliccourses + module summaries (teaser)
GET/courses/modules/{id}usermodule detail: lessons + progress + test + cert
POST/courses/modules/{id}/enrolluserenroll in a module
GET/courses/lessons/{id}useropen lesson (first start begins free period)
POST/courses/lessons/{id}/completeusermark lesson completed
GET/courses/modules/{id}/testusertest (unlocked after lessons; no answers leaked)
POST/courses/modules/{id}/testusergrade attempt; pass → complete + certificate
GET/courses/certificates/mineusermy certificates
GET/certificates/{vid}publiccertificate lookup by verification ID
POST/GET/courses/leadusersubmit (upsert) / read my lead
GET/admin/courses/leadsadminall leads
POST/admin/courses/leads/{id}/reviewadminmark new/valid/invalid (valid → full access)
CRUD/admin/courses[...]admincourses, modules, lessons
GET/POST/admin/courses/modules/{id}/testadminread / save (replace) the module test

Test cases

CaseExpected
New user opens first lessonallowed; free_period_ends_at set to now+10d
Expired user (no full access) opens lesson403 "Your free period has ended."
Test before all lessons done400 "Complete the lessons before taking the test."
Test questions to clientoptions have NO is_correct
Wrong answerspassed=false, attempt recorded
Correct answers ≥75%passed=true, module completed, certificate issued
Certificate lookup by IDname + module + attempt number
Expired user submits leadlead stored (status new); modal stops
Admin marks lead validuser has_full_access=true, lesson accessible again

Business rules enforced

  • Enroll per module; module completed = all lessons completed AND test passed.
  • Free period global per account: clock starts on the FIRST lesson started anywhere

(Courses triggers Accounts to set free_period_ends_at = now + courses.free_period_days).

  • Access = valid free period OR has_full_access; on expiry lessons lock, lead modal shows.
  • One test per module (v1); multiple-choice single-answer; pass ≥ courses.test_pass_threshold

(75); unlimited attempts recorded with attempt_number; answers never sent to the client.

  • Certificate issued once per user+module (verification ID + passing attempt + branding);

emailed with a link; PDF via browser print (decision 6B).

  • One company lead per user (upsert); admin notified by email; valid grants full access (audited).

Frontend

  • Public /courses — catalog with module summaries.
  • /courses/modules/:id — enroll, lesson list with progress, test link, certificate link.
  • /courses/lessons/:id — lesson content + custom YouTube player (nocookie, rel=0,

modestbranding, title mask) + mark complete.

  • /courses/modules/:id/test — radio-per-question quiz + result (score/pass/retake).
  • /certificate/:vid — printable certificate (browser print-to-PDF).
  • Global lead modal (shown after expiry, every navigation, until submitted).
  • Admin /admin/courses — content manager (courses/modules/lessons/test editor) + leads tab.
  • EN/FR strings; nav: public Courses, admin Courses ⚙.
7.3_build/implemented.md

Landaro — Implemented (Milestone 1)

← build README

Backend foundation (shared, used by all modules)

  • PSR-4 autoloader (no Composer), front controller + regex router.
  • Shared API envelope { data, error, meta }, error codes + HTTP statuses from

shared/api-contract.md.

  • PDO database layer (parameterized queries), UTC storage / Europe-Bucharest display.
  • Hand-written JWT (HS256), sessions table for logout/expiry.
  • CORS for the Angular dev server; Validator; Pagination (10/25/50/100, max 100).
  • Mailer via PHPMailer 6.9.3 (in backend/lib/PHPMailer); disk fallback when SMTP blank.

Accounts & Admin — endpoints

MethodPathRolePurpose
POST/auth/registerpubliccreate pending user + activation email
POST/auth/activatepublicactivate via single-use token
POST/auth/resend-activationpublicresend activation (no email enumeration)
POST/auth/loginpublicissue JWT + session (remember me)
POST/auth/logoutuserinvalidate session
GET/auth/meusercurrent user
PUT/PATCH/profileuserupdate own details / password / locale / email
POST/profile/confirm-emailpublicconfirm email change (re-verification)
GET/admin/usersadminsearch + filter + paginate
GET/admin/users/{id}adminuser detail
PATCH/admin/users/{id}/statusadminactivate / block / unblock
PATCH/admin/users/{id}/roleadminchange role
POST/admin/users/{id}/extend-free-periodadminadd free days (audited)
GET/admin/settingsadminlist settings (with defaults)
PUT/admin/settingsadminupsert a setting (audited)

Business rules enforced

  • Password: min 8 chars, ≥1 letter + ≥1 digit (live strength bar in UI).
  • Unique email; generic login failure; blocked/pending login messages.
  • Activation before use; single-use, expiring tokens (hashed, never stored raw).
  • Remember-me (7d) vs short session (1d); session 7d / 1d, activation 24h, reset 1h.
  • Email change requires re-verifying the new address; old email kept until confirmed.
  • Admin actions written to audit_log (status, role, free-period, settings).
  • Access model helper: access = valid free period OR has_full_access.

Frontend (Angular 20)

  • Header with nav + EN/FR language selector (JSON dictionaries, saved on account).
  • Pages: Home, Login, Register (strength bar), Activate / Confirm-email, Profile,

Admin Users (table + actions + pagination), Admin Settings.

  • Auth via signals + JWT interceptor; route guards (authGuard, adminGuard).

Verified

Health, admin login, /auth/me, admin user list + settings, register (201), weak password (400), duplicate email (400), pending login (403), unauthorized (401), activation + single-use reuse (400), CORS preflight (204). Frontend builds + serves; i18n assets load.

Decisions applied from _questions-build.md

B (module-by-module) · symlink serving · PHPMailer fetched into lib · real SMTP (you fill creds) · certificate PDF = browser print (later module) · one test per module · is_featured included · legal daily limit counts all questions that day · custom player per recommendation (later module).

7.4_build/legal.md

Landaro — Legal (Milestone 3)

← build README

Two parts: a public FAQ, and a gated user↔consultant chat.

Endpoints

MethodPathRolePurpose
GET/legal/faqpublicpublished FAQ grouped by category
GET/legal/categoriespublicLegal categories
GET/legal/disclaimerpublicdisclaimer text (setting)
POST/legal/questionsuserask (gated + daily limit)
GET/legal/questions/mineuserown questions
GET/legal/questions/poolconsultant/adminunclaimed pool
GET/legal/questions/claimedconsultantmy claimed
POST/legal/questions/{id}/claimconsultant/adminexclusive self-claim
GET/legal/questions/{id}participantthread + messages
POST/legal/questions/{id}/messagesparticipantpost message
POST/legal/questions/{id}/resolveparticipantmark resolved
POST/legal/questions/{id}/reopenuser/adminreopen
GET/POST/PUT/DELETE/admin/legal/faq[...]admin/consultantFAQ authoring
POST/DELETE/admin/legal/categories[...]admincategories
GET/admin/legal/questionsadminall questions
POST/admin/legal/questions/{id}/assignadminassign/reassign
GET/admin/legal/statisticsadminglobal + per-consultant metrics

Test cases

CaseExpected
Public FAQ / disclaimer200
No-access user asks403 lead message
Daily limit reached429 "Daily limit for questions reached."
First consultant claims200 "You claimed this question."
Second consultant claims same400 "This question has already been claimed."
Non-participant posts403
User resolves, then reopensstatus resolved → claimed
Pool without consultant/admin401
Statisticscounts by status + per-consultant averages

Business rules enforced

  • FAQ public = published only; admin + consultants author; one category + optional image.
  • Ask gated by access model (valid free period OR has_full_access).
  • Daily limit = legal.max_questions_per_day (default 3), counts all questions created

since local midnight (Europe/Bucharest), any status.

  • Statuses open → claimed → resolved; reopen → claimed. Posting on a resolved thread reopens it.
  • Self-claim is atomic/exclusive (conditional UPDATE); admin can assign/reassign.
  • Participant authorization: asking user, assigned consultant, or admin.
  • Disclaimer from legal.disclaimer_text, shown on FAQ + chat screens.
  • Statistics: raw metrics only (no composite score).

Frontend

  • Public /legal — disclaimer + grouped FAQ accordion + ask entry point.
  • User /legal/my-questions — ask form + question list + chat (reusable chat component).
  • Consultant /consultant — unclaimed pool (claim) + my claimed + chat.
  • Admin /admin/legal — tabs: FAQ management (+ categories), questions overview (assign),

statistics (global + per-consultant).

  • Reusable app-legal-chat (chronological bubbles, send/resolve/reopen).
  • EN/FR strings; nav links: public Legal, user My questions, Consultant, admin Legal ⚙.
7.5_build/news.md

Landaro — News (Milestone 2)

← build README

Endpoints

MethodPathRolePurpose
GET/news/articlespublicfeed: published + published_at<=now, filters, search, paginated
GET/news/articles/{slug}publicsingle article + related (same category, recent)
GET/news/categoriespubliclist categories
GET/news/tagspubliclist tags
GET/admin/news/articlesadminall articles (drafts included)
GET/admin/news/articles/{id}adminone article for editing
POST/admin/news/articlesadmincreate (draft or published)
PUT/admin/news/articles/{id}adminupdate
DELETE/admin/news/articles/{id}admindelete
POST/admin/news/categoriesadmincreate category
PUT/admin/news/categories/{id}adminupdate category
DELETE/admin/news/categories/{id}admindelete (blocked if in use)
POST/admin/news/uploadadminupload feature image (png/jpg/webp, 5MB)

Test cases

CaseExpected
Feed (public)200, newest + featured first, only published & due
Article by slug200 with related list
Create curated without source400 "A curated article must credit its source."
Create curated with 60-word bodycontent trimmed to 50 words + "…"
Create curated with bad source_url400 "Please enter a valid source link."
Create original200/201, no source fields, body untouched
Publishappears in public feed
Admin endpoints without admin token401/403
Duplicate category name400 "A category with this name already exists."

Business rules enforced

  • Two types: curated (source required, 50-word trim + "…") / original (no source).
  • Title + content + exactly one category required; unique slugs per table.
  • Only published + published_at<=now are public; drafts admin-only.
  • Any number of tags (auto-created by name); related = same category, recent, excl. self.
  • is_featured included (change request #1) and pins to the top of the feed.
  • Feature image optional; placeholder shown when absent; local-disk upload.

Frontend

  • Public: /news (feed with search + category filter, cards), /news/:slug

(article; curated shows "Source: … / read the full story", original shows full body; related articles).

  • Admin: /admin/news — category manager + article list + editor (type toggle reveals

source fields, tags, image upload, schedule date, featured, draft/publish/delete).

  • EN/FR strings added; nav links added for public News and admin News.
7.6_build/run.md

Landaro — Running

← build README

Start the frontend

From ../frontend/:

npx ng serve --port 4200

Open http://localhost:4200.

Backend

No start step — Apache (MAMP) serves it through the htdocs symlink at http://localhost:8888/landaro-api. Just make sure MAMP's Apache + MySQL are running.

Quick smoke test (curl)

BASE=http://localhost:8888/landaro-api

# Health
curl -s $BASE/health

# Admin login
curl -s -X POST $BASE/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"test@test.com","password":"test","remember":true}'

Try the UI flows

  1. Register a user → check ../backend/storage/mail/ for the activation email →

open the activate?token=... link path in the app → account becomes active.

  1. Log in as the user; edit the Profile, change language (EN/FR) in the header.
  2. Log in as admin (test@test.com / test) → Admin → Users: search, filter,

activate/block, change role, extend free period. Admin → Settings: edit keys.

Re-lint / rebuild

# Backend syntax check
find ../backend/src ../backend/public -name '*.php' -print0 | xargs -0 -n1 \
  /Applications/MAMP/bin/php/php8.3.30/bin/php -l

# Frontend production build
cd ../frontend && npx ng build
7.7_build/setup.md

Landaro — One-time setup

← build README

1. Database (schema + seed)

From ../backend/:

PHP=/Applications/MAMP/bin/php/php8.3.30/bin/php
$PHP scripts/migrate.php    # creates the `landaro` database + all tables
$PHP scripts/seed.php       # first admin + default settings

Seeded admin (per _decisions.md, changeable later): test@test.com / test.

2. Serve the API through Apache (symlink into htdocs)

Already created during the build:

ln -sfn "/Users/pixeltime/kiro/_devschool/projects/landaro/backend/public" \
        "/Applications/MAMP/htdocs/landaro-api"

API base: http://localhost:8888/landaro-api.

3. SMTP (optional, for real emails)

Edit ../backend/config/config.php → mail block (host, port, username, password, from). Leave blank to keep writing emails to ../backend/storage/mail/.

4. Frontend dependencies

Already installed. If needed again, from ../frontend/:

npm install

Ports in use

  • Apache :8888 → serves the PHP API via the htdocs symlink.
  • MySQL :8889 → MAMP database.
  • Angular dev server :4200 → the frontend (CORS allowed by the API).
7.8_build/test-users.md

Landaro — Test users

Test accounts, all with the password test. They are pre-activated (status = active), so no email activation is needed. They are created by backend/scripts/seed.php (idempotent — re-running it does not duplicate them).

EmailPasswordRoleStatusNotes
test@test.comtestadminactivethe original seeded admin
test@test.rotestadminactiveadmin added on request
user@test.rotestuseractivesimple user; free period starts on the first lesson
consultant@test.rotestconsultantactivelegal consultant

What each role can do (short)

  • admin — everything: users, settings, News, Legal (FAQ + statistics), Courses + leads.
  • user — own profile, ask a consultant (chat), courses + tests + certificates.
  • consultant — claim and answer legal questions, write FAQ entries.

Where to log in

Frontend: http://localhost:4200 → Log in.

Notes

  • user@test.ro has not started the free period yet and does not have has_full_access.

When the period expires (or to test the lock), the company-lead modal appears. If you want this user to have permanent full access, tell me and I will set has_full_access = 1.

  • To recreate the accounts after a fresh migrate:

php scripts/seed.php (from the backend/ folder).

7.9database/README.md

Landaro — Database

This folder holds everything needed to set up the Landaro database.

FileWhat it is
schema.sqlempty schema only (all tables, no data)
landaro-backup.sqlfull backup: schema + all demo data (news, FAQ, questions, courses, test users)

Fastest start — import the full backup

landaro-backup.sql is self-contained: it creates the landaro database if needed, drops and recreates every table, and loads all the data. Importing it is all you need — no migrate/seed required.

Just ask Kiro: "import database/landaro-backup.sql into my MySQL database".

Or run it yourself (MAMP MySQL on port 8889, user/pass root/root):

/Applications/MAMP/Library/bin/mysql80/bin/mysql \
  --host=127.0.0.1 --port=8889 --user=root --password=root \
  < database/landaro-backup.sql
Note: importing replaces the Landaro tables (DROP + CREATE). If you already have data you want to keep, back it up first.

Alternative — build from scratch

If you want an empty database and then generate fresh data:

cd backend
php scripts/migrate.php      # create schema
php scripts/seed.php         # first admin + test users + default settings
php scripts/demo-seed.php    # 20 news, 50 FAQ, 10 questions, 10 courses

Test users (all password test)

See ../_build/test-users.md. Quick reference: test@test.com (admin), test@test.ro (admin), user@test.ro (user), consultant@test.ro (consultant).

Refreshing the backup

After changing data locally, regenerate the backup with:

/Applications/MAMP/Library/bin/mysql80/bin/mysqldump \
  --host=127.0.0.1 --port=8889 --user=root --password=root \
  --no-tablespaces --single-transaction --skip-comments \
  --add-drop-table --databases landaro > database/landaro-backup.sql
7.10frontend/README.md

Frontend

This project was generated using Angular CLI version 20.3.5.

Development server

To start a local development server, run:

ng serve

Once the server is running, open your browser and navigate to http://localhost:4200/. The application will automatically reload whenever you modify any of the source files.

Code scaffolding

Angular CLI includes powerful code scaffolding tools. To generate a new component, run:

ng generate component component-name

For a complete list of available schematics (such as components, directives, or pipes), run:

ng generate --help

Building

To build the project run:

ng build

This will compile your project and store the build artifacts in the dist/ directory. By default, the production build optimizes your application for performance and speed.

Running unit tests

To execute unit tests with the Karma test runner, use the following command:

ng test

Running end-to-end tests

For end-to-end (e2e) testing, run:

ng e2e

Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.

Additional Resources

For more information on using the Angular CLI, including detailed command references, visit the Angular CLI Overview and Command Reference page.