Tutorial: a multi-user todo list
The smallest real multi-user app: everyone keeps their own todo list and nobody can see anyone else's, even through the API. You will add a rule, a daily email of overdue items per person, verify it and put it on a link. It takes about fifteen minutes.
What you will learn
- Private rows with
accessset toown. - A business rule, a generated list and form page.
- A scheduled workflow that emails each person their own items.
1. Ask your agent
Use appbricx. Create a project "Tick": a personal todo list. Each member
sees and edits only their own todos (title, notes, due date, done). A todo
needs a title. Every morning at 09:00 London time, email each member the
list of their overdue todos. Seed two members. Check, apply, verify.No agent? Paste the same text into the builder.
2. The contract
This is a contract that does it. It was run through the production check with no errors.
{
"app": {
"name": "Tick",
"tagline": "Your list, nobody else's",
"brand_color": "#2563EB",
"nav_style": "topbar"
},
"personas": [
{
"id": "member",
"label": "Member",
"description": "keeps a personal todo list",
"kind": "consumer"
}
],
"coordinates": {
"primaryVerb": "track",
"inventoryShape": "records",
"moneySurface": "none"
},
"entities": [
{
"name": "member",
"label_field": "name",
"fields": [
{
"name": "name",
"type": "text"
},
{
"name": "email",
"type": "email",
"optional": true
}
],
"access": {
"member": {
"read": "own",
"update": "own"
}
}
},
{
"name": "todo",
"label_field": "title",
"fields": [
{
"name": "title",
"type": "text"
},
{
"name": "notes",
"type": "text",
"optional": true
},
{
"name": "due_on",
"type": "date",
"optional": true
},
{
"name": "done",
"type": "boolean",
"optional": true
}
],
"access": {
"member": {
"read": "own",
"create": true,
"update": "own",
"delete": "own"
}
},
"rules": [
{
"id": "has-title",
"on": "both",
"message": "Give the todo a title.",
"check": "return String(row.title ?? '').trim().length > 0;"
}
]
}
],
"seeds": {
"member": [
{
"id": "m1",
"name": "Ava Patel",
"email": "ava@example.com"
},
{
"id": "m2",
"name": "Noah Kim",
"email": "noah@example.com"
}
],
"todo": [
{
"id": "t1",
"title": "Renew passport",
"due_on": "2026-10-10",
"done": false
},
{
"id": "t2",
"title": "Book dentist",
"due_on": "2026-10-14",
"done": false
},
{
"id": "t3",
"title": "Send invoice to Acme",
"done": true
}
]
},
"demo_logins": [
{
"persona": "member",
"row": "m2"
}
],
"pages": [
{
"name": "todos",
"route": "/todos",
"title": "My todos",
"icon": "ListChecks",
"personas": [
"member"
],
"layout": {
"kind": "stack",
"children": [
{
"kind": "header",
"title": "My todos",
"subtitle": "Only you can see this list."
},
{
"kind": "block",
"block": "EntityList",
"props": {
"entity": "todo",
"presentation": "rows",
"columns": [
"title",
"due_on",
"done"
],
"searchField": "title",
"filters": [
{
"field": "done",
"label": "Done"
}
],
"emptyCopy": "Nothing on your list. Add the first thing you keep forgetting."
}
}
]
},
"form": {
"kind": "block",
"block": "EntityForm",
"props": {
"entity": "todo",
"mode": "both",
"fields": [
"title",
"notes",
"due_on",
"done"
],
"submitLabel": "Save todo"
}
}
}
],
"workflows": [
{
"id": "overdue-digest",
"title": "Email each member their overdue todos",
"on": {
"schedule": "daily 09:00",
"entity": "todo",
"tz": "Europe/London"
},
"do": [
{
"action": "custom",
"code": "const today = ctx.now().toISOString().slice(0, 10);\nconst todos = await ctx.db.list('todo');\nconst late = todos.filter((t) => t.done !== true && t.due_on && String(t.due_on).slice(0, 10) < today);\nconst byOwner = new Map<string, string[]>();\nfor (const t of late) {\n const k = String(t.created_by);\n byOwner.set(k, [...(byOwner.get(k) ?? []), String(t.title)]);\n}\nconst members = await ctx.db.list('member');\nfor (const [owner, titles] of byOwner) {\n const m = members.find((x) => String(x.user_id) === owner);\n if (!m || !m.email) continue;\n await ctx.email({ to: String(m.email), subject: `You have ${titles.length} overdue todos`, body: titles.map((x) => `- ${x}`).join('\\n') });\n}\nctx.log(`digest: ${byOwner.size} members`);"
}
]
}
]
}"access": { "member": { "read": "own", ... } }ontodoandmember: each person sees only their rows. Postgres enforces it.- The
has-titlerule refuses an empty title for every caller. demo_loginsadds a second member login, so you can check that Ava can't see Noah's list.
3. The digest, formatted
A schedule has no row, and an email action on a schedule goes to a whole role. To send each person only their own items, the workflow loops in a custom step:
const today = ctx.now().toISOString().slice(0, 10);
const todos = await ctx.db.list('todo');
const late = todos.filter((t) => t.done !== true && t.due_on && String(t.due_on).slice(0, 10) < today);
const byOwner = new Map<string, string[]>();
for (const t of late) {
const k = String(t.created_by);
byOwner.set(k, [...(byOwner.get(k) ?? []), String(t.title)]);
}
const members = await ctx.db.list('member');
for (const [owner, titles] of byOwner) {
const m = members.find((x) => String(x.user_id) === owner);
if (!m || !m.email) continue;
await ctx.email({ to: String(m.email), subject: `You have ${titles.length} overdue todos`, body: titles.map((x) => `- ${x}`).join('\n') });
}
ctx.log(`digest: ${byOwner.size} members`);Workflows run as the platform, so ctx.db.list sees every todo. The step groups them by created_byand finds each member's email through their account. Email goes through the platform's sender with no setup.
4. Apply, check as each user, verify
- Apply. The reply lists two member sign-ins.
- Open the preview as each one. Add a todo as Ava; sign in as Noah: it isn't there.
- Run verify. The data stage proves the same for every table:
Permissions match the plan, and signed-out visitors can't readtodoormember. - Run the digest now instead of waiting: Backend, Workflows, Run now on
overdue-digest, or ask your agent torunit. The run log showsdigest: N members.
5. Deploy
appbricx deploy --subdomain tickYour list is live at https://tick.apps.appbricx.com, with a snapshot of production taken first.
Gotchas
- Seeded todos belong to nobody (they have no
created_by), so no member sees them. Create todos while signed in to see them. - Schedules run in UTC unless you set
tz. - Want the digest from your own domain? Connect Resend, SendGrid, Gmail or SMTP. See messages.