Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | 55x 31x 31x 5x 5x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 36x | import { eq } from "drizzle-orm";
import type { Database } from "@/lib/db";
import { user, account, session, verification } from "@/lib/db/schema";
import type { User } from "@/types";
import { withTransaction } from "@/lib/transactions";
import { accountTombstone } from "@/server/services/account-tombstone";
export class UserRepository {
constructor(private db: Database) {}
async findById(id: string): Promise<User | null> {
const [result] = await this.db
.select()
.from(user)
.where(eq(user.id, id));
return result ? this.mapToUser(result) : null;
}
async findByEmail(email: string): Promise<User | null> {
const [result] = await this.db
.select()
.from(user)
.where(eq(user.email, email));
return result ? this.mapToUser(result) : null;
}
async update(
id: string,
// Off the drizzle table's insert shape so it tracks NOT NULL columns (e.g.
// `name` cannot be null under better-auth's canonical `user`).
data: Partial<Omit<typeof user.$inferInsert, "id" | "createdAt">>,
): Promise<User> {
const [result] = await this.db
.update(user)
.set(data)
.where(eq(user.id, id))
.returning();
return this.mapToUser(result);
}
/**
* Permanently scrub PII from the user row and delete all auth records, in ONE
* transaction. Called AFTER all owned/borrowed collection data has been erased.
*
* better-auth owns auth now, so the credential/session state lives in its
* canonical tables:
* - email → tombstone (keeps the unique index valid)
* - name → tombstone (user.name is NOT NULL) / image → null / emailVerified → false
* - account rows (incl. the scrypt password) → hard-deleted by user_id
* - session rows → hard-deleted by user_id (logs the user out everywhere)
* - verification rows for the OLD email → hard-deleted (no stale magic-link)
*
* Returns the captured oldEmail so the caller can scrub other rows (e.g.
* invitations) that identify the user by that email.
*/
async purgeAndScrub(id: string): Promise<{ oldEmail: string | null }> {
return withTransaction(this.db, async (tx) => {
// Capture the old email before scrubbing so we can purge verifications.
const [existing] = await tx.select({ email: user.email }).from(user).where(eq(user.id, id));
const oldEmail = existing?.email ?? null;
// Delete auth records first: `account` holds the scrypt password, `session`
// holds live tokens. These would cascade if the user row were dropped, but
// we keep the row scrubbed as a tombstone instead.
await tx.delete(account).where(eq(account.userId, id));
await tx.delete(session).where(eq(session.userId, id));
Eif (oldEmail) {
await tx.delete(verification).where(eq(verification.identifier, oldEmail));
}
// Scrub PII on the user row; keep the row so FK references (e.g.
// audit_log.actor_id, collections.owner_id on already-purged collections)
// remain resolvable as a tombstone. `name` is NOT NULL, so it is set to the
// tombstone email rather than null.
const tombstone = accountTombstone(id);
await tx
.update(user)
.set({
email: tombstone,
name: tombstone,
image: null,
emailVerified: false,
updatedAt: new Date(),
})
.where(eq(user.id, id));
return { oldEmail };
});
}
private mapToUser(row: typeof user.$inferSelect): User {
return {
id: row.id,
email: row.email,
name: row.name,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: null,
};
}
}
|