Skip to content
24 changes: 21 additions & 3 deletions src/lib/server/deletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,32 @@ import { eq } from 'drizzle-orm';
import { expect, test } from 'vitest';

import { DAY_MS, seedConsent, setupTestDb, testDb } from './testdb';
import { auditLog, channels, comments, consents, moderationActions, rules, sessions, users } from './db/schema';
import { auditLog, channels, comments, consents, invites, memberships, moderationActions, organizations, rules, sessions, users } from './db/schema';
import {
CONSENT_EMAIL_RETENTION_MS,
consentEmailCutoffIso,
deleteUserRecords,
nullExpiredConsentEmails
} from './deletion';

setupTestDb(['moderation_actions', 'comments', 'audit_log', 'rules', 'channels', 'sessions', 'consents', 'users']);
setupTestDb(['moderation_actions', 'comments', 'audit_log', 'rules', 'channels', 'sessions', 'consents', 'invites', 'memberships', 'organizations', 'users']);

async function seedUser(id: string) {
await testDb()
.db.insert(users)
.values({ id, googleSub: `sub-${id}`, email: `${id}@example.com`, displayName: id });
// Every real user has a personal org (0012 backfill / signup) — an org row
// named after them, an owner membership, and (Phase D shape) an invite.
await testDb()
.db.insert(organizations)
.values({ id: `org-${id}`, name: id, personalFor: id });
await testDb().db.insert(memberships).values({ userId: id, orgId: `org-${id}`, role: 'owner' });
await testDb()
.db.insert(invites)
.values({ token: `invite-${id}`, orgId: `org-${id}`, role: 'member', createdBy: id, expiresAt: new Date(Date.now() + DAY_MS).toISOString() });
await testDb()
.db.insert(channels)
.values({ id: `UC-${id}`, userId: id, title: `channel ${id}`, refreshTokenEnc: 'enc' });
.values({ id: `UC-${id}`, userId: id, orgId: `org-${id}`, title: `channel ${id}`, refreshTokenEnc: 'enc' });
await testDb()
.db.insert(sessions)
.values({ id: `token-${id}`, userId: id, expiresAt: new Date(Date.now() + DAY_MS).toISOString() });
Expand Down Expand Up @@ -80,6 +89,11 @@ test('deleteUserRecords erases every owned record and tombstones the user fully'
expect(await testDb().db.select().from(moderationActions).all()).toEqual([]);
expect(await testDb().db.select().from(auditLog).all()).toEqual([]);
expect(await testDb().db.select().from(rules).all()).toEqual([]);
// The user's tenancy goes too: the personal org (its name is the user's
// display name — PII), all memberships, and invites they created.
expect(await testDb().db.select().from(organizations).all()).toEqual([]);
expect(await testDb().db.select().from(memberships).all()).toEqual([]);
expect(await testDb().db.select().from(invites).all()).toEqual([]);
expect(await userRow(userId)).toMatchObject({
googleSub: `deleted:${userId}`,
email: '[deleted]',
Expand All @@ -102,6 +116,10 @@ test('deleteUserRecords leaves other users and their records alone', async () =>
expect((await testDb().db.select().from(channels).all()).map((ch) => ch.id)).toEqual(['UC-stays']);
expect(await testDb().db.select().from(sessions).all()).toHaveLength(1);
expect(await testDb().db.select().from(consents).all()).toHaveLength(1);
// ...including the survivor's tenancy.
expect((await testDb().db.select().from(organizations).all()).map((o) => o.id)).toEqual(['org-stays']);
expect(await testDb().db.select().from(memberships).all()).toHaveLength(1);
expect(await testDb().db.select().from(invites).all()).toHaveLength(1);
});

test('deleteUserRecords works for a user with no channels', async () => {
Expand Down
26 changes: 23 additions & 3 deletions src/lib/server/deletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import { and, eq, inArray, isNotNull, lt } from 'drizzle-orm';

import { db } from '$lib/server/db';
import { auditLog, channels, comments, consents, moderationActions, rules, sessions, users } from '$lib/server/db/schema';
import { auditLog, channels, comments, consents, invites, memberships, moderationActions, organizations, rules, sessions, users } from '$lib/server/db/schema';

export const CONSENT_EMAIL_RETENTION_MS = 10 * 365.25 * 24 * 60 * 60 * 1000; // 10 years

Expand All @@ -50,8 +50,12 @@ export function consentEmailCutoffIso(now = Date.now()): string {
* Immediately and permanently erases a user's account data, preserving only the anonymized tombstone and the consent log.
*
* One transaction: moderation actions, comments, audit rows, and rules for
* the user's channels; the channels themselves; every session. The users row
* is anonymized to a tombstone (`googleSub: 'deleted:<id>'`, e-mail and
* the user's channels; the channels themselves; every session; and the
* user's tenancy — the personal org (whose name is the user's display name,
* i.e. PII), every membership, and every invite they created. Explicit
* deletes in child-to-parent order rather than FK reliance: the users row is
* only tombstoned, so ON DELETE CASCADE never fires. The users row is
* anonymized to a tombstone (`googleSub: 'deleted:<id>'`, e-mail and
* display name wiped) so the same Google identity can sign up again and the
* `consents` evidentiary log survives with its foreign key intact. The
* e-mail survives ONLY in `consents` (statutory retention, Art. 16, III) —
Expand Down Expand Up @@ -81,6 +85,22 @@ export async function deleteUserRecords(userId: string): Promise<void> {
}
await tx.delete(channels).where(eq(channels.userId, userId));
await tx.delete(sessions).where(eq(sessions.userId, userId));
// Tenancy erasure. The personal org is single-member by definition; a
// shared org the user merely belongs to survives (its other members own
// it) — only the user's membership row leaves.
const personalOrgs = await tx
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.personalFor, userId))
.all();
const orgIds = personalOrgs.map((o) => o.id);
if (orgIds.length) {
await tx.delete(invites).where(inArray(invites.orgId, orgIds));
await tx.delete(memberships).where(inArray(memberships.orgId, orgIds));
await tx.delete(organizations).where(inArray(organizations.id, orgIds));
}
await tx.delete(invites).where(eq(invites.createdBy, userId));
await tx.delete(memberships).where(eq(memberships.userId, userId));
await tx
.update(users)
.set({ googleSub: `deleted:${userId}`, email: '[deleted]', displayName: '[deleted]' })
Expand Down
65 changes: 65 additions & 0 deletions src/lib/server/org.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Moderaty — YouTube Comment Auto-Moderation Tool
// Copyright (C) 2026 Andrew Philip Weilbacher
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Commercial licensing: contact@marketingprowess.simplelogin.com — see COMMERCIAL.md

import { expect, test } from 'vitest';

import { setupTestDb, testDb } from './testdb';
import { memberships, organizations, users } from './db/schema';
import { listOrgMemberships, resolveActiveOrg } from './org';

// Phase D adds the team-management behavior tests to this file; these cover
// the session-resolution core shipped in Phase B.
setupTestDb(['memberships', 'organizations', 'users']);

async function seedUserWithOrgs(userId: string, orgIds: string[], createdAt: string) {
await testDb()
.db.insert(users)
.values({ id: userId, googleSub: `sub-${userId}`, email: `${userId}@example.com`, displayName: userId });
for (const orgId of orgIds) {
await testDb().db.insert(organizations).values({ id: orgId, name: orgId });
await testDb().db.insert(memberships).values({ userId, orgId, role: 'member', createdAt });
}
}

test('memberships tying on created_at resolve deterministically, consistently with the org list', async () => {
// PR #49 review (Qodo/Codacy): "oldest membership" must be a total order —
// a timestamp tie (batched inserts) must not let the active org flip with
// undefined DB row order. Tie-break is org id, and the nav list must agree.
// Inserted b-first on purpose: insertion order must NOT decide the winner.
await seedUserWithOrgs('user-1', ['org-b', 'org-a'], '2026-01-01T00:00:00.000Z');

const resolved = await resolveActiveOrg('user-1', null);
expect(resolved?.org.orgId).toBe('org-a');

const list = await listOrgMemberships('user-1');
expect(list.map((o) => o.orgId)).toEqual(['org-a', 'org-b']);
});

test('fellBack is false when the session had no explicit active org', async () => {
// PR #49 review (Qodo): fellBack means "an explicit org choice became
// invalid" — a null active_org_id is the ordinary fresh-login case, not a
// fallback.
await seedUserWithOrgs('user-1', ['org-a'], '2026-01-01T00:00:00.000Z');

const fresh = await resolveActiveOrg('user-1', null);
expect(fresh?.fellBack).toBe(false);

const vanished = await resolveActiveOrg('user-1', 'org-gone');
expect(vanished?.fellBack).toBe(true);
expect(vanished?.org.orgId).toBe('org-a');
});
109 changes: 109 additions & 0 deletions src/lib/server/org.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Moderaty — YouTube Comment Auto-Moderation Tool
// Copyright (C) 2026 Andrew Philip Weilbacher
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Commercial licensing: contact@marketingprowess.simplelogin.com — see COMMERCIAL.md

// DIY organization tenancy — no auth library, per the project's dependency policy.

import { randomBytes } from 'node:crypto';

import { eq } from 'drizzle-orm';

import { db } from '$lib/server/db';
import { memberships, organizations } from '$lib/server/db/schema';

export type OrgRole = 'owner' | 'admin' | 'member';

export interface OrgContext {
orgId: string;
orgName: string;
orgRole: OrgRole;
plan: string;
}

/** Narrows a raw memberships.role string to OrgRole, failing loudly on data bugs. */
export function asOrgRole(role: string): OrgRole {
if (role === 'owner' || role === 'admin' || role === 'member') return role;
throw new Error(`unknown membership role: ${role}`);
}

/** All of a user's memberships joined to their orgs, oldest membership first (ties by org id), in SQL. */
async function fetchMembershipRows(userId: string) {
return db
.select({
orgId: organizations.id,
orgName: organizations.name,
plan: organizations.plan,
role: memberships.role,
membershipCreatedAt: memberships.createdAt
})
.from(memberships)
.innerJoin(organizations, eq(memberships.orgId, organizations.id))
.where(eq(memberships.userId, userId))
.orderBy(memberships.createdAt, memberships.orgId)
.all();
}

/**
* Resolves a user's active organization: the session's active_org_id when a
* membership for it still exists, otherwise the user's OLDEST membership
* (deterministic fallback — timestamp ties break by org id, in SQL). Returns
* null only when the user has zero memberships — a data bug the caller must
* treat as fatal, never as signed-out. `fellBack` is true only when an
* explicit activeOrgId was supplied and no longer has a membership.
*/
export async function resolveActiveOrg(
userId: string,
activeOrgId: string | null
): Promise<{ org: OrgContext; fellBack: boolean } | null> {
const rows = await fetchMembershipRows(userId);
if (rows.length === 0) return null;
const chosen = rows.find((r) => r.orgId === activeOrgId) ?? rows[0];
return {
org: { orgId: chosen.orgId, orgName: chosen.orgName, orgRole: asOrgRole(chosen.role), plan: chosen.plan },
fellBack: activeOrgId !== null && chosen.orgId !== activeOrgId
};
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

/** Every org the user belongs to, oldest membership first (ties by org id) — feeds the nav team switcher. */
export async function listOrgMemberships(userId: string) {
const rows = await fetchMembershipRows(userId);
return rows.map(({ orgId, orgName, role }) => ({ orgId, name: orgName, role: asOrgRole(role) }));
}

/**
* Returns the id of the user's personal org, creating it (plus the owner
* membership) when missing. Naming matches the 0012 backfill (the user's
* display name). Idempotent per I4: a concurrent same-sub signup that lost
* the user-insert race finds the org the winner already made. Callers that
* must commit atomically with other writes (account creation) pass their
* transaction as `handle`.
*/
export async function ensurePersonalOrg(
handle: Pick<typeof db, 'insert' | 'select'>,
user: { id: string; displayName: string }
): Promise<string> {
const existing = await handle
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.personalFor, user.id))
.get();
if (existing) return existing.id;
const orgId = randomBytes(16).toString('hex');
await handle.insert(organizations).values({ id: orgId, name: user.displayName, personalFor: user.id });
await handle.insert(memberships).values({ userId: user.id, orgId, role: 'owner' });
return orgId;
}
68 changes: 66 additions & 2 deletions src/lib/server/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ import { eq } from 'drizzle-orm';
import { expect, test } from 'vitest';

import { setupTestDb, testDb } from './testdb';
import { sessions, users } from './db/schema';
import { memberships, organizations, sessions, users } from './db/schema';
import { createSession, destroySession, getSessionUser, SESSION_TTL_MS } from './session';

setupTestDb(['sessions', 'users']);
setupTestDb(['sessions', 'memberships', 'organizations', 'users']);

async function seedUser(id = 'user-1') {
await testDb().db.insert(users).values({ id, googleSub: `sub-${id}`, email: `${id}@example.com`, displayName: id });
// Every surviving user has a personal org + owner membership (Phase A backfill shape).
await testDb().db.insert(organizations).values({ id: `org-${id}`, name: id, personalFor: id });
await testDb().db.insert(memberships).values({ userId: id, orgId: `org-${id}`, role: 'owner' });
return id;
}

Expand Down Expand Up @@ -114,3 +117,64 @@ test('creating a session purges already-expired rows', async () => {
expect(ids).not.toContain('stale-token');
expect(ids).toContain('live-token');
});

test('a session with active_org_id set resolves that org id/name/role/plan onto the user', async () => {
const userId = await seedUser();
// Plan comes from the ORGANIZATION, never the legacy users.plan.
await testDb().db.update(organizations).set({ plan: 'pro' }).where(eq(organizations.id, 'org-user-1'));

const { token } = await createSession(userId, undefined, 'org-user-1');
const result = await getSessionUser(token);

expect(result).toMatchObject({
user: { id: userId, plan: 'pro', orgId: 'org-user-1', orgName: 'user-1', orgRole: 'owner' }
});
});

test('a session with active_org_id NULL resolves the oldest membership', async () => {
const userId = await seedUser(); // personal org membership: created now
await testDb().db.insert(organizations).values({ id: 'org-newer', name: 'Newer Team' });
await testDb().db.insert(memberships).values({
userId,
orgId: 'org-newer',
role: 'member',
createdAt: new Date(Date.now() + 60_000).toISOString() // strictly newer
});

const { token } = await createSession(userId); // activeOrgId null
const result = await getSessionUser(token);

expect(result?.user.orgId).toBe('org-user-1');
expect(result?.user.orgRole).toBe('owner');
});

test('a session whose active org membership vanished falls back to the oldest membership and is repaired', async () => {
const userId = await seedUser();
await testDb().db.insert(organizations).values({ id: 'org-newer', name: 'Newer Team' });
await testDb().db.insert(memberships).values({
userId,
orgId: 'org-newer',
role: 'member',
createdAt: new Date(Date.now() + 60_000).toISOString()
});
const { token } = await createSession(userId, undefined, 'org-newer');
// The user leaves (or is removed from) the session's active org.
await testDb().db.delete(memberships).where(eq(memberships.orgId, 'org-newer'));

const result = await getSessionUser(token);

expect(result?.user.orgId).toBe('org-user-1');
const repaired = await testDb().db.select().from(sessions).where(eq(sessions.id, token)).get();
expect(repaired?.activeOrgId).toBe('org-user-1');
});

test('a user with zero memberships makes getSessionUser throw, never sign out', async () => {
// Zero memberships is a data bug (Phase A backfill guarantees one) — fail
// loudly rather than improvise access or read as signed-out.
await testDb()
.db.insert(users)
.values({ id: 'bare', googleSub: 'sub-bare', email: 'bare@example.com', displayName: 'bare' });
const { token } = await createSession('bare');

await expect(getSessionUser(token)).rejects.toThrow('account has no organization');
});
Loading