diff --git a/src/db/api/ApiInterface.ts b/src/db/api/ApiInterface.ts index bb85948..9a68d4a 100644 --- a/src/db/api/ApiInterface.ts +++ b/src/db/api/ApiInterface.ts @@ -267,24 +267,33 @@ export class ApiInterface implements DbInterface { } async listNotes(): Promise { - throw new Error("Unimplemented"); + return this.#call("GET", "/notes"); } async getNoteById(nid: string): Promise { - throw new Error("Unimplemented"); + return this.#call("GET", "/notes/:nid", { params: { nid } }); } async deleteNoteById(nid: string): Promise { - throw new Error("Unimplemented"); + this.#checkReadonly(); + await this.#call("DELETE", "/notes/:nid", { params: { nid } }); } - async createNote(note: CreateNote): Promise { - throw new Error("Unimplemented"); + async createNote(note: CreateNote, _ctx: { uid: string }): Promise { + this.#checkReadonly(); + return this.#call("POST", "/abodes/:aid/notes", { + params: { aid: note.aid }, + body: note, + }); } - async updateNote(note: UpdateNote): Promise { - throw new Error("Unimplemented"); + async updateNote(note: UpdateNote, _ctx: { uid: string }): Promise { + this.#checkReadonly(); + return this.#call("PATCH", "/notes/:nid", { + params: { nid: note.nid }, + body: note, + }); } async listNotesByAbodeId(aid: string): Promise { - throw new Error("Unimplemented"); + return this.#call("GET", "/abodes/:aid/notes", { params: { aid } }); } async listNotesByUserId(uid: string): Promise { - throw new Error("Unimplemented"); + return this.#call("GET", "/users/:uid/notes", { params: { uid } }); } } diff --git a/src/db/sqlite/SqliteInterface.ts b/src/db/sqlite/SqliteInterface.ts index 3775863..a8f2332 100644 --- a/src/db/sqlite/SqliteInterface.ts +++ b/src/db/sqlite/SqliteInterface.ts @@ -33,6 +33,8 @@ import { selectClientApikeys, selectClientUser, selectClientUsers, + selectNote, + selectPartialNotes, selectResident, selectResidents, } from "./query.js"; @@ -465,24 +467,71 @@ export class SqliteInterface implements BackendDbInterface { } async listNotes(): Promise { - throw new Error("Unimplemented"); + return selectPartialNotes(this.#db); + } + #getNoteById(nid: string): Note { + const note = selectNote(this.#db, sql`n."nid" = ${{ uuid: nid }}`); + if (!note) throw new NotFoundAbodeError(); + return note; } async getNoteById(nid: string): Promise { - throw new Error("Unimplemented"); + return this.#getNoteById(nid); } async deleteNoteById(nid: string): Promise { - throw new Error("Unimplemented"); + this.#checkReadonly(); + const { changes } = this.#db.run( + sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}` + ); + if (!changes) throw new NotFoundAbodeError(); } async createNote(note: CreateNote, ctx: { uid: string }): Promise { - throw new Error("Unimplemented"); + this.#checkReadonly(); + const nid = crypto.randomUUID(); + return this.#db.rethrow(() => + this.#db.multi(() => { + this.#db.run(sql` + INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by") + VALUES( + ${{ uuid: nid }}, + ${{ uuid: note.aid }}, + ${{ text: note.name }}, + ${{ text: note.content ?? "" }}, + ${{ jsonb: note.properties }}, + ${{ uuid: ctx.uid }}, + ${{ uuid: ctx.uid }} + ) + `); + return this.#getNoteById(nid); + }) + ); } async updateNote(note: UpdateNote, ctx: { uid: string }): Promise { - throw new Error("Unimplemented"); + this.#checkReadonly(); + const updates = calcUpdates({ + name: (value: string) => sql`"name" = ${{ text: value }}`, + content: (value: string) => sql`"content" = ${{ text: value }}`, + properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`, + })(note); + if (!updates.length) throw new InvalidAbodeError(); + return this.#db.rethrow(() => + this.#db.multi(() => { + const { changes } = this.#db.run(sql` + UPDATE "notes" + SET + "updated_at" = datetime('now', 'localtime', 'subsec'), + "updated_by" = ${{ uuid: ctx.uid }}, + ${joinSql(updates, sql`, `)} + WHERE "nid" = ${{ uuid: note.nid }} + `); + if (!changes) throw new NotFoundAbodeError(); + return this.#getNoteById(note.nid); + }) + ); } async listNotesByAbodeId(aid: string): Promise { - throw new Error("Unimplemented"); + return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`); } async listNotesByUserId(uid: string): Promise { - throw new Error("Unimplemented"); + return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`); } } diff --git a/src/db/sqlite/cast.ts b/src/db/sqlite/cast.ts index 129cca9..c4e9115 100644 --- a/src/db/sqlite/cast.ts +++ b/src/db/sqlite/cast.ts @@ -1,5 +1,12 @@ import type { Abode } from "../types/Abode.js"; import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js"; +import type { + Note, + NoteProperties, + NoteType, + PartialNote, + PartialNoteProperties, +} from "../types/Note.js"; import type { Resident, ResidentFlags } from "../types/Resident.js"; import type { ClientUser, PartialUser, UserFlags } from "../types/User.js"; @@ -160,3 +167,71 @@ export function sqliteToClientApikey(apikey: { expires_at: apikey.expires_at ? sqliteToDate(apikey.expires_at) : null, }; } + +const validNoteTypes = new Set(["note"]); +export function sqliteToNoteProperties(props: string): NoteProperties { + const parsed = JSON.parse(props); + const out: NoteProperties = {}; + if ( + typeof parsed === "object" && + parsed && + !Array.isArray(parsed) && + validNoteTypes.has(parsed.type) + ) { + out.type = parsed.type; + } + return out; +} + +export function sqliteToPartialNoteProperties( + props: string +): PartialNoteProperties { + const base = sqliteToNoteProperties(props); + return { type: base.type ?? "note" }; +} + +export function sqliteToNote(note: { + nid: Buffer | Uint8Array; + aid: Buffer | Uint8Array; + name: string; + content: string; + properties: string; + created_at: string; + created_by: Buffer | Uint8Array | null; + updated_at: string; + updated_by: Buffer | Uint8Array | null; +}): Note { + return { + nid: sqliteToUuid(note.nid), + aid: sqliteToUuid(note.aid), + name: note.name, + content: note.content, + properties: sqliteToNoteProperties(note.properties), + created_at: sqliteToDate(note.created_at), + created_by: note.created_by ? sqliteToUuid(note.created_by) : null, + updated_at: sqliteToDate(note.updated_at), + updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null, + }; +} + +export function sqliteToPartialNote(note: { + nid: Buffer | Uint8Array; + aid: Buffer | Uint8Array; + name: string; + properties: string; + created_at: string; + created_by: Buffer | Uint8Array | null; + updated_at: string; + updated_by: Buffer | Uint8Array | null; +}): PartialNote { + return { + nid: sqliteToUuid(note.nid), + aid: sqliteToUuid(note.aid), + name: note.name, + properties: sqliteToPartialNoteProperties(note.properties), + created_at: sqliteToDate(note.created_at), + created_by: note.created_by ? sqliteToUuid(note.created_by) : null, + updated_at: sqliteToDate(note.updated_at), + updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null, + }; +} diff --git a/src/db/sqlite/query.ts b/src/db/sqlite/query.ts index 75fff69..79534f9 100644 --- a/src/db/sqlite/query.ts +++ b/src/db/sqlite/query.ts @@ -1,11 +1,14 @@ import type { Abode } from "../types/Abode.js"; import type { ClientApikey } from "../types/Apikey.js"; +import type { Note, PartialNote } from "../types/Note.js"; import type { Resident } from "../types/Resident.js"; import type { ClientUser } from "../types/User.js"; import { sqliteToAbode, sqliteToClientApikey, sqliteToClientUser, + sqliteToNote, + sqliteToPartialNote, sqliteToResident, } from "./cast.js"; import type { WrappedDb } from "./impl/types.js"; @@ -122,3 +125,46 @@ export function selectClientApikeys( ); return rawApikeys.map(sqliteToClientApikey); } + +type RawNote = { + nid: Buffer | Uint8Array; + aid: Buffer | Uint8Array; + name: string; + content: string; + properties: string; + created_at: string; + created_by: Buffer | Uint8Array | null; + updated_at: string; + updated_by: Buffer | Uint8Array | null; +}; +const sqlNote = sql` + SELECT n."nid", n."aid", n."name", n."content", json(n."properties") AS "properties", + n."created_at", n."created_by", n."updated_at", n."updated_by" + FROM "notes" n +`; + +type RawPartialNote = Omit; +const sqlPartialNote = sql` + SELECT n."nid", n."aid", n."name", json(n."properties") AS "properties", + n."created_at", n."created_by", n."updated_at", n."updated_by" + FROM "notes" n +`; + +export function selectNote(db: WrappedDb, where: SqlCode): Note | null { + const raw = db.get(sql`${sqlNote} WHERE ${where}`); + if (raw) return sqliteToNote(raw); + return null; +} +export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] { + const raws = db.all(where ? sql`${sqlNote} WHERE ${where}` : sqlNote); + return raws.map(sqliteToNote); +} +export function selectPartialNotes( + db: WrappedDb, + where?: SqlCode +): PartialNote[] { + const raws = db.all( + where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote + ); + return raws.map(sqliteToPartialNote); +} diff --git a/src/webapi/apirouter.ts b/src/webapi/apirouter.ts index 6c5c9ac..b34b360 100644 --- a/src/webapi/apirouter.ts +++ b/src/webapi/apirouter.ts @@ -11,6 +11,7 @@ import { loginuser, updateabode, updateresident, + updatenote, updateuser, } from "../schema/validators.js"; import { authenticate } from "./middleware/authenticate.js"; @@ -94,11 +95,11 @@ export function apirouter(db: BackendDbInterface): KoaRouter { await db.deleteApikeyById(ctx.params.kid); ctx.status = 204; }); - router.post("/user/:uid/auth/clear-sessions", async (ctx) => { + router.post("/users/:uid/auth/clear-sessions", async (ctx) => { await db.deleteSessionsByUser(ctx.params.uid); ctx.status = 204; }); - router.get("/user/:uid/notes", async (ctx) => { + router.get("/users/:uid/notes", async (ctx) => { ctx.body = await db.listNotesByUserId(ctx.params.uid); }); @@ -188,7 +189,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter { }); router.patch( "/notes/:nid", - jsonBody({ includeParams: ["nid"] }), + jsonBody({ validate: updatenote, includeParams: ["nid"] }), async (ctx) => { ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid }); }