import type { BackendDbInterface } from "../types/DbInterface.js"; import { isValidUserPassword, type ClientUser, type CreateUser, type LoginUser, type UpdateUser, type UserFlags, } from "../types/User.js"; import { pgToClientUser, pgToDate } from "./cast.js"; import { ConflictAbodeError, InvalidAbodeError, NotAuthorizedAbodeError, NotFoundAbodeError, ReadonlyAbodeError, } from "../types/errors.js"; import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js"; import type { CreateResident, Resident, ResidentFlags, updateResident, } from "../types/Resident.js"; import { validatePassword } from "../../util/hash.js"; import { createApikeyToken, createSessionToken } from "../../util/token.js"; import type { ClientApikey, CreateApikey } from "../types/Apikey.js"; import { calcUpdates, catSql, joinSql, sql } from "./sql.js"; import { selectAbode, selectAbodes, selectClientApikey, selectClientApikeys, selectClientUser, selectClientUsers, selectResident, selectResidents, } from "./query.js"; import type { CreateNote, Note, PartialNote, UpdateNote, } from "../types/Note.js"; import type { WrappedPgClient } from "./pool.js"; export class PostgresInterface implements BackendDbInterface { #db: WrappedPgClient; constructor(db: WrappedPgClient) { this.#db = db; } #checkReadonly(): void { if (this.#db.readonly) throw new ReadonlyAbodeError(); } get _() { return { sql, catSql, joinSql, db: this.#db, }; } get readonly(): boolean { return this.#db.readonly; } get backend(): true { return true; } get name(): "postgres" { return "postgres"; } async close(): Promise { await this.#db.destroy(); } async listUsers(): Promise { return selectClientUsers(this.#db); } async #getUserById(uid: string, db: WrappedPgClient): Promise { const user = await selectClientUser(db, sql`u."uid" = ${{ uuid: uid }}`); if (!user) throw new NotFoundAbodeError(); return user; } async getUserById(id: string): Promise { return this.#getUserById(id, this.#db); } async getUserByEmail(email: string): Promise { const user = await selectClientUser( this.#db, sql`u."email" = ${{ text: email }}`, ); if (!user) throw new NotFoundAbodeError(); return user; } async getUserByLogin({ email, password }: LoginUser): Promise { const rawUser = await this.#db.get<{ uid: string; email: string; name: string; flags: unknown; created_at: Date; updated_at: Date; password: string; }>( sql` SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password" FROM "users" WHERE "email" = ${{ text: email }} `, ); if (!rawUser) throw new NotFoundAbodeError(); if (rawUser.password.startsWith("#")) throw new ConflictAbodeError(); if (!(await validatePassword(password, rawUser.password))) throw new NotAuthorizedAbodeError(); return pgToClientUser(rawUser); } async deleteUserById(id: string): Promise { this.#checkReadonly(); const { changes } = await this.#db.run( sql` DELETE FROM "users" WHERE "uid" = ${{ uuid: id }} `, ); if (!changes) throw new NotFoundAbodeError(); } async createUser(user: CreateUser): Promise { this.#checkReadonly(); if (!isValidUserPassword(user.password)) throw new InvalidAbodeError(); const uid = crypto.randomUUID(); return this.#db.rethrow(() => this.#db.multi(async (tx) => { await tx.run( sql` INSERT INTO "users"("uid", "email", "name", "password", "flags") VALUES( ${{ uuid: uid }}, ${{ text: user.email }}, ${{ text: user.name }}, ${{ text: user.password }}, ${{ jsonb: user.flags }} ) `, ); return this.#getUserById(uid, tx); }), ); } async updateUser(user: UpdateUser): Promise { this.#checkReadonly(); return this.#db.rethrow(() => this.#db.multi(async (tx) => { const updates = []; if ("email" in user && user.email !== undefined) updates.push(sql`"email" = ${{ text: user.email }}`); if ("name" in user && user.name !== undefined) updates.push(sql`"name" = ${{ text: user.name }}`); if ("password" in user && user.password !== undefined) { if (!isValidUserPassword(user.password)) throw new InvalidAbodeError(); if (user.password.startsWith("#")) { await tx.run(sql` DELETE FROM "apikeys" WHERE "uid" = ${{ uuid: user.uid }} `); } await tx.run(sql` DELETE FROM "sessions" WHERE "uid" = ${{ uuid: user.uid }} `); updates.push(sql`"password" = ${{ text: user.password }}`); } if ("flags" in user && user.flags !== undefined) updates.push(sql`"flags" = ${{ jsonb: user.flags as UserFlags }}`); if (!updates.length) throw new InvalidAbodeError(); const { changes } = await tx.run( sql` UPDATE "users" SET "updated_at" = NOW(), ${joinSql(updates, sql`, `)} WHERE "uid" = ${{ uuid: user.uid }} `, ); if (!changes) throw new NotFoundAbodeError(); return this.#getUserById(user.uid, tx); }), ); } async listAbodes(): Promise { return selectAbodes(this.#db); } async #getAbodeById(aid: string, db: WrappedPgClient): Promise { const abode = await selectAbode(db, sql`a."aid" = ${{ uuid: aid }}`); if (!abode) throw new NotFoundAbodeError(); return abode; } async getAbodeById(id: string): Promise { return this.#getAbodeById(id, this.#db); } async deleteAbodeById(id: string): Promise { this.#checkReadonly(); const { changes } = await this.#db.run( sql` DELETE FROM "abodes" WHERE "aid" = ${{ uuid: id }} `, ); if (!changes) throw new NotFoundAbodeError(); } async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise { this.#checkReadonly(); const aid = crypto.randomUUID(); return this.#db.rethrow(() => this.#db.multi(async (tx) => { await tx.run( sql` INSERT INTO "abodes"("aid", "name", "created_by", "updated_by") VALUES( ${{ uuid: aid }}, ${{ text: abode.name }}, ${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }} ) `, ); return this.#getAbodeById(aid, tx); }), ); } async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise { this.#checkReadonly(); const updates = calcUpdates({ name: (value: string) => sql`"name" = ${{ text: value }}`, })(abode); if (!updates.length) throw new InvalidAbodeError(); return this.#db.rethrow(() => this.#db.multi(async (tx) => { const { changes } = await tx.run( sql` UPDATE "abodes" SET "updated_at" = NOW(), "updated_by" = ${{ uuid: ctx.uid }}, ${joinSql(updates, sql`, `)} WHERE "aid" = ${{ uuid: abode.aid }} `, ); if (!changes) throw new NotFoundAbodeError(); return this.#getAbodeById(abode.aid, tx); }), ); } async listResidents(): Promise { return selectResidents(this.#db); } async listResidentsByUserId(uid: string): Promise { return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`); } async listResidentsByAbodeId(aid: string): Promise { return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`); } async #getResidentById( uid: string, aid: string, db: WrappedPgClient, ): Promise { const resident = await selectResident( db, sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`, ); if (!resident) throw new NotFoundAbodeError(); return resident; } async getResidentById(uid: string, aid: string): Promise { return this.#getResidentById(uid, aid, this.#db); } async deleteResidentById(uid: string, aid: string): Promise { this.#checkReadonly(); const { changes } = await this.#db.run( sql` DELETE FROM "residents" WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }} `, ); if (!changes) throw new NotFoundAbodeError(); } async createResident( resident: CreateResident, ctx: { uid: string }, ): Promise { this.#checkReadonly(); return this.#db.rethrow(() => this.#db.multi(async (tx) => { await tx.run( sql` INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by") VALUES( ${{ uuid: resident.uid }}, ${{ uuid: resident.aid }}, ${{ jsonb: resident.flags }}, ${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }} ) `, ); return this.#getResidentById(resident.uid, resident.aid, tx); }), ); } async updateResident( resident: updateResident, ctx: { uid: string }, ): Promise { this.#checkReadonly(); const updates = calcUpdates({ flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`, })(resident); if (!updates.length) throw new InvalidAbodeError(); return this.#db.rethrow(() => this.#db.multi(async (tx) => { const { changes } = await tx.run( sql` UPDATE "residents" SET "updated_at" = NOW(), "updated_by" = ${{ uuid: ctx.uid }}, ${joinSql(updates, sql`, `)} WHERE "uid" = ${{ uuid: resident.uid }} AND "aid" = ${{ uuid: resident.aid }} `, ); if (!changes) throw new NotFoundAbodeError(); return this.#getResidentById(resident.uid, resident.aid, tx); }), ); } async listUsersByAbodeId(id: string): Promise { return selectClientUsers( this.#db, sql` JOIN "residents" r ON u."uid" = r."uid" WHERE r."aid" = ${{ uuid: id }} `, ); } async listAbodesByUserId(id: string): Promise { return selectAbodes( this.#db, sql` JOIN "residents" r ON a."aid" = r."aid" WHERE r."uid" = ${{ uuid: id }} `, ); } async getUserBySession(token: `as_${string}`): Promise { const session = await this.#db.get<{ uid: string; expires_at: Date; }>(sql` SELECT "uid", "expires_at" FROM "sessions" WHERE "token" = ${{ text: token }} `); if (!session) throw new NotFoundAbodeError(); if (new Date(pgToDate(session.expires_at)).getTime() < Date.now()) { if (!this.readonly) { await this.#db.run(sql` DELETE FROM "sessions" WHERE "expires_at" < NOW() `); } throw new NotFoundAbodeError(); } if (!this.readonly) { await this.#db.run(sql` UPDATE "sessions" SET "expires_at" = NOW() + INTERVAL '7 days' WHERE "token" = ${{ text: token }} `); } return this.#getUserById(session.uid, this.#db); } async createSession(uid: string): Promise<`as_${string}`> { this.#checkReadonly(); const token = createSessionToken(); await this.#db.run(sql` INSERT INTO "sessions"("uid", "token") VALUES(${{ uuid: uid }}, ${{ text: token }}) `); return token; } async deleteSessionsByUser(uid: string): Promise { this.#checkReadonly(); await this.#db.run(sql` DELETE FROM "sessions" WHERE "uid" = ${{ uuid: uid }} `); } async deleteSession(token: `as_${string}`): Promise { this.#checkReadonly(); await this.#db.run(sql` DELETE FROM "sessions" WHERE "token" = ${{ text: token }} `); } async #getApikeyByToken( token: `at_${string}`, db: WrappedPgClient, ): Promise { const apikey = await selectClientApikey( db, sql`k."token" = ${{ text: token }}`, ); if (!apikey) throw new NotFoundAbodeError(); return apikey; } async getUserByApikey( token: `at_${string}`, ): Promise<[ClientUser, ClientApikey]> { const apikey = await this.#getApikeyByToken(token, this.#db); if ( apikey.expires_at && new Date(apikey.expires_at).getTime() < Date.now() ) { throw new NotAuthorizedAbodeError(); } return [await this.#getUserById(apikey.uid, this.#db), apikey]; } async listApikeysByUser(uid: string): Promise { return selectClientApikeys(this.#db, sql`k."uid" = ${{ uuid: uid }}`); } async getApikeyById(kid: string): Promise { const apikey = await selectClientApikey( this.#db, sql`k."kid" = ${{ uuid: kid }}`, ); if (!apikey) throw new NotFoundAbodeError(); return apikey; } async createApikey( apikey: CreateApikey, ): Promise<[ClientApikey, `at_${string}`]> { this.#checkReadonly(); const token = createApikeyToken(); const kid = crypto.randomUUID(); let expires = apikey.expires_at; if (expires === undefined) expires = new Date( new Date().getTime() + 1000 * 60 * 60 * 24 * 365, ).toISOString(); if (expires && new Date(expires).getTime() < Date.now()) throw new InvalidAbodeError(); await this.#db.run(sql` INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at") VALUES( ${{ uuid: apikey.uid }}, ${{ uuid: kid }}, ${{ text: token }}, ${{ text: apikey.name }}, ${{ jsonb: apikey.permissions }}, ${expires ? { date: expires } : { null: true }} ) `); return [await this.#getApikeyByToken(token, this.#db), token]; } async deleteApikeyById(kid: string): Promise { this.#checkReadonly(); const { changes } = await this.#db.run(sql` DELETE FROM "apikeys" WHERE "kid" = ${{ uuid: kid }} `); if (!changes) throw new NotFoundAbodeError(); } async listNotes(): Promise { throw new Error("Unimplemented"); } async getNoteById(_nid: string): Promise { throw new Error("Unimplemented"); } async deleteNoteById(_nid: string): Promise { throw new Error("Unimplemented"); } async createNote(_note: CreateNote, _ctx: { uid: string }): Promise { throw new Error("Unimplemented"); } async updateNote(_note: UpdateNote, _ctx: { uid: string }): Promise { throw new Error("Unimplemented"); } async listNotesByAbodeId(_aid: string): Promise { throw new Error("Unimplemented"); } async listNotesByUserId(_uid: string): Promise { throw new Error("Unimplemented"); } }