CI / install-and-build (pull_request) Successful in 1m29s
CI / format (pull_request) Successful in 51s
CI / typecheck-source (pull_request) Successful in 41s
CI / typecheck-tests (pull_request) Successful in 39s
CI / test (pull_request) Successful in 51s
CI / lint (pull_request) Successful in 21s
Co-Authored-By: gpt-5.6-terra <noreply@openai.com>
512 lines
15 KiB
TypeScript
512 lines
15 KiB
TypeScript
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<void> {
|
|
await this.#db.destroy();
|
|
}
|
|
|
|
async listUsers(): Promise<ClientUser[]> {
|
|
return selectClientUsers(this.#db);
|
|
}
|
|
async #getUserById(uid: string, db: WrappedPgClient): Promise<ClientUser> {
|
|
const user = await selectClientUser(db, sql`u."uid" = ${{ uuid: uid }}`);
|
|
if (!user) throw new NotFoundAbodeError();
|
|
return user;
|
|
}
|
|
async getUserById(id: string): Promise<ClientUser> {
|
|
return this.#getUserById(id, this.#db);
|
|
}
|
|
async getUserByEmail(email: string): Promise<ClientUser> {
|
|
const user = await selectClientUser(
|
|
this.#db,
|
|
sql`u."email" = ${{ text: email }}`,
|
|
);
|
|
if (!user) throw new NotFoundAbodeError();
|
|
return user;
|
|
}
|
|
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
|
|
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<void> {
|
|
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<ClientUser> {
|
|
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<ClientUser> {
|
|
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<Abode[]> {
|
|
return selectAbodes(this.#db);
|
|
}
|
|
async #getAbodeById(aid: string, db: WrappedPgClient): Promise<Abode> {
|
|
const abode = await selectAbode(db, sql`a."aid" = ${{ uuid: aid }}`);
|
|
if (!abode) throw new NotFoundAbodeError();
|
|
return abode;
|
|
}
|
|
async getAbodeById(id: string): Promise<Abode> {
|
|
return this.#getAbodeById(id, this.#db);
|
|
}
|
|
async deleteAbodeById(id: string): Promise<void> {
|
|
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<Abode> {
|
|
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<Abode> {
|
|
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<Resident[]> {
|
|
return selectResidents(this.#db);
|
|
}
|
|
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
|
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
|
}
|
|
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
|
return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
|
}
|
|
async #getResidentById(
|
|
uid: string,
|
|
aid: string,
|
|
db: WrappedPgClient,
|
|
): Promise<Resident> {
|
|
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<Resident> {
|
|
return this.#getResidentById(uid, aid, this.#db);
|
|
}
|
|
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
|
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<Resident> {
|
|
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<Resident> {
|
|
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<ClientUser[]> {
|
|
return selectClientUsers(
|
|
this.#db,
|
|
sql`
|
|
JOIN "residents" r ON u."uid" = r."uid"
|
|
WHERE r."aid" = ${{ uuid: id }}
|
|
`,
|
|
);
|
|
}
|
|
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
|
return selectAbodes(
|
|
this.#db,
|
|
sql`
|
|
JOIN "residents" r ON a."aid" = r."aid"
|
|
WHERE r."uid" = ${{ uuid: id }}
|
|
`,
|
|
);
|
|
}
|
|
|
|
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
|
|
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<void> {
|
|
this.#checkReadonly();
|
|
await this.#db.run(sql`
|
|
DELETE FROM "sessions"
|
|
WHERE "uid" = ${{ uuid: uid }}
|
|
`);
|
|
}
|
|
|
|
async deleteSession(token: `as_${string}`): Promise<void> {
|
|
this.#checkReadonly();
|
|
await this.#db.run(sql`
|
|
DELETE FROM "sessions"
|
|
WHERE "token" = ${{ text: token }}
|
|
`);
|
|
}
|
|
|
|
async #getApikeyByToken(
|
|
token: `at_${string}`,
|
|
db: WrappedPgClient,
|
|
): Promise<ClientApikey> {
|
|
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<ClientApikey[]> {
|
|
return selectClientApikeys(this.#db, sql`k."uid" = ${{ uuid: uid }}`);
|
|
}
|
|
async getApikeyById(kid: string): Promise<ClientApikey> {
|
|
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<void> {
|
|
this.#checkReadonly();
|
|
const { changes } = await this.#db.run(sql`
|
|
DELETE FROM "apikeys"
|
|
WHERE "kid" = ${{ uuid: kid }}
|
|
`);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
}
|
|
|
|
async listNotes(): Promise<PartialNote[]> {
|
|
throw new Error("Unimplemented");
|
|
}
|
|
async getNoteById(_nid: string): Promise<Note> {
|
|
throw new Error("Unimplemented");
|
|
}
|
|
async deleteNoteById(_nid: string): Promise<void> {
|
|
throw new Error("Unimplemented");
|
|
}
|
|
async createNote(_note: CreateNote, _ctx: { uid: string }): Promise<Note> {
|
|
throw new Error("Unimplemented");
|
|
}
|
|
async updateNote(_note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
|
|
throw new Error("Unimplemented");
|
|
}
|
|
async listNotesByAbodeId(_aid: string): Promise<PartialNote[]> {
|
|
throw new Error("Unimplemented");
|
|
}
|
|
async listNotesByUserId(_uid: string): Promise<PartialNote[]> {
|
|
throw new Error("Unimplemented");
|
|
}
|
|
}
|