feat: implement notes CRUD for SQLite and API backends
Fills in all 7 previously-unimplemented note methods in SqliteInterface and ApiInterface, adds cast/query helpers for notes, and fixes the apirouter (missing updatenote validator, two /user/ → /users/ typos that were also bypassing auth middleware). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -266,24 +266,33 @@ export class ApiInterface implements DbInterface {
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/notes");
|
||||
}
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/notes/:nid", { params: { nid } });
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
throw new Error("Unimplemented");
|
||||
this.#checkReadonly();
|
||||
await this.#call("DELETE", "/notes/:nid", { params: { nid } });
|
||||
}
|
||||
async createNote(note: CreateNote): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
async createNote(note: CreateNote, _ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("POST", "/abodes/:aid/notes", {
|
||||
params: { aid: note.aid },
|
||||
body: note,
|
||||
});
|
||||
}
|
||||
async updateNote(note: UpdateNote): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
async updateNote(note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
return this.#call("PATCH", "/notes/:nid", {
|
||||
params: { nid: note.nid },
|
||||
body: note,
|
||||
});
|
||||
}
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/abodes/:aid/notes", { params: { aid } });
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#call("GET", "/users/:uid/notes", { params: { uid } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
selectClientApikeys,
|
||||
selectClientUser,
|
||||
selectClientUsers,
|
||||
selectNote,
|
||||
selectPartialNotes,
|
||||
selectResident,
|
||||
selectResidents,
|
||||
} from "./query.js";
|
||||
@@ -458,24 +460,71 @@ export class SqliteInterface implements BackendDbInterface {
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
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<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
return this.#getNoteById(nid);
|
||||
}
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
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<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NoteType>(["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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<RawNote, "content">;
|
||||
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<RawNote>(sql`${sqlNote} WHERE ${where}`);
|
||||
if (raw) return sqliteToNote(raw);
|
||||
return null;
|
||||
}
|
||||
export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] {
|
||||
const raws = db.all<RawNote>(where ? sql`${sqlNote} WHERE ${where}` : sqlNote);
|
||||
return raws.map(sqliteToNote);
|
||||
}
|
||||
export function selectPartialNotes(
|
||||
db: WrappedDb,
|
||||
where?: SqlCode
|
||||
): PartialNote[] {
|
||||
const raws = db.all<RawPartialNote>(
|
||||
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote
|
||||
);
|
||||
return raws.map(sqliteToPartialNote);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
loginuser,
|
||||
updateabode,
|
||||
updateresident,
|
||||
updatenote,
|
||||
updateuser,
|
||||
} from "../schema/validators.js";
|
||||
import { authenticate } from "./middleware/authenticate.js";
|
||||
@@ -92,11 +93,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);
|
||||
});
|
||||
|
||||
@@ -186,7 +187,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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user