1. Define the persistence layer
Copy the last file in backend/migrations/ and save it as the next number, NNN_tasks.ts. Migrations run in file-name order, so the number is what places it last. Fill it in:
// backend/migrations/NNN_tasks.ts
import type { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('tasks', (t) => {
t.string('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
t.string('title').notNullable();
t.boolean('completed').notNullable().defaultTo(false);
// users.id is varchar (NextAuth adapter ids), not uuid - an FK column
// must match the referenced column's type or the migration fails.
// CASCADE fires on a real DELETE; account deletion ghosts the row instead.
t.string('user_id')
.notNullable()
.references('id')
.inTable('users')
.onDelete('CASCADE');
// created_at, updated_at. Postgres has no ON UPDATE clause, so
// updated_at only gets its DEFAULT now() at insert time. Content
// updates set updated_at: new Date() explicitly, as the service's
// update() below does; a deliberate counter bump like a view count
// does not.
t.timestamps(true, true);
t.index(['user_id']);
t.index(['created_at']);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('tasks');
}
Then add the row interface in backend/src/db/types.ts:
export interface TaskRow {
id: string;
title: string;
completed: boolean;
user_id: string;
created_at: Date;
updated_at: Date;
}
Run npm run migrate to apply.
Decide what account deletion does to this table
Account deletion anonymizes the users row and keeps it, so the onDelete('CASCADE') above never fires for one, and UsersService.ghostDelete reaches only the tables it names. Full scope: Architecture. Pick one:
- Nothing personal in here (foreign ids, counters, config). Leave it. The ghosted
usersrow keeps the foreign key resolvable. - It can hold personal data, meaning anything free text a user types. Erase it inside
ghostDelete's transaction, where a failure rolls the whole erasure back rather than leaving it half done.itemsis the worked example.
// backend/src/users/users.service.ts, inside ghostDelete's transaction
await trx('tasks').where({ user_id: userId }).del();
// or, when the row has to stay for counts, the way email_jobs does:
await trx('tasks').where({ user_id: userId }).update({ title: '[deleted]' });
Either way, add the table to DELETION_POLICY in backend/src/users/deletion-policy.ts: the erasure it receives (items is the deleted-rows example), or the reason it needs none. A table whose rows survive deletion, like the update() variant above, also lists every column as erased or kept (email_jobs is the example). deletion-policy.spec.ts fails until every table on disk has an entry, and deletion-policy.integration.spec.ts fails on a column the lists do not name. A red test there means this decision has not been made yet: come back and make it, rather than reverse-engineering the fix from the failure.