| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
import type { Database } from "bun:sqlite"; |
| 10 |
import { record, type AuditRow } from "./audit.ts"; |
| 11 |
import { permit } from "./contract.ts"; |
| 12 |
import { emit } from "./live.ts"; |
| 13 |
import { kick } from "./draw.ts"; |
| 14 |
import { isKind, KINDS, PROMPT_MAX, type Kind } from "./glyph.ts"; |
| 15 |
import type { Who } from "./who.ts"; |
| 16 |
|
| 17 |
export interface Drawing { |
| 18 |
readonly id: number; |
| 19 |
readonly prompt: string; |
| 20 |
readonly kind: Kind; |
| 21 |
readonly status: "drawing" | "done" | "failed"; |
| 22 |
|
| 23 |
readonly svgs: string; |
| 24 |
readonly error: string | null; |
| 25 |
readonly author_id: string; |
| 26 |
readonly author_name: string; |
| 27 |
readonly created_at: string; |
| 28 |
readonly updated_at: string; |
| 29 |
} |
| 30 |
|
| 31 |
export type Failure = |
| 32 |
| "signin_required" |
| 33 |
| "forbidden" |
| 34 |
| "not_found" |
| 35 |
| "invalid" |
| 36 |
| "busy"; |
| 37 |
export type Result<T> = |
| 38 |
| { ok: true; value: T } |
| 39 |
| { ok: false; error: Failure; message: string }; |
| 40 |
const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 41 |
const fail = <T>(error: Failure, message: string): Result<T> => ({ |
| 42 |
ok: false, |
| 43 |
error, |
| 44 |
message, |
| 45 |
}); |
| 46 |
|
| 47 |
|
| 48 |
const refuse = <T>(who: Who, signIn: string, no: string): Result<T> => |
| 49 |
who.role === "anon" ? fail("signin_required", signIn) : fail("forbidden", no); |
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
|
| 55 |
|
| 56 |
const audited = <T extends { readonly id: number }>( |
| 57 |
db: Database, |
| 58 |
who: Who, |
| 59 |
resource: string, |
| 60 |
verb: "create" | "update" | "delete", |
| 61 |
target: number | undefined, |
| 62 |
result: Result<T>, |
| 63 |
): Result<T> => { |
| 64 |
record(db, { |
| 65 |
who, |
| 66 |
resource, |
| 67 |
verb, |
| 68 |
target: result.ok ? result.value.id : target, |
| 69 |
outcome: result.ok ? "ok" : result.error, |
| 70 |
}); |
| 71 |
return result; |
| 72 |
}; |
| 73 |
|
| 74 |
|
| 75 |
export const svgsOf = (drawing: Drawing): string[] => { |
| 76 |
try { |
| 77 |
const parsed: unknown = JSON.parse(drawing.svgs); |
| 78 |
return Array.isArray(parsed) ? parsed.filter((s) => typeof s === "string") : []; |
| 79 |
} catch { |
| 80 |
return []; |
| 81 |
} |
| 82 |
}; |
| 83 |
|
| 84 |
|
| 85 |
export const AT_ONCE = 3; |
| 86 |
|
| 87 |
export function listDrawings( |
| 88 |
db: Database, |
| 89 |
who: Who, |
| 90 |
input: { limit?: number } = {}, |
| 91 |
): Result<Drawing[]> { |
| 92 |
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50) || 50, 1), 200); |
| 93 |
|
| 94 |
|
| 95 |
const may = permit(who, "drawing", "list"); |
| 96 |
if (may === "no") |
| 97 |
return refuse(who, "Sign in to see your drawings.", "You may not see these drawings."); |
| 98 |
if (may === "yes") |
| 99 |
return ok( |
| 100 |
db |
| 101 |
.query<Drawing, [number]>("SELECT * FROM drawing ORDER BY id DESC LIMIT ?1") |
| 102 |
.all(limit), |
| 103 |
); |
| 104 |
const mine = may.where.map((column) => `${column} = ?2`).join(" OR "); |
| 105 |
return ok( |
| 106 |
db |
| 107 |
.query<Drawing, [number, string]>( |
| 108 |
`SELECT * FROM drawing WHERE ${mine} ORDER BY id DESC LIMIT ?1`, |
| 109 |
) |
| 110 |
.all(limit, who.role === "anon" ? "" : who.id), |
| 111 |
); |
| 112 |
} |
| 113 |
|
| 114 |
|
| 115 |
|
| 116 |
|
| 117 |
|
| 118 |
export const startDrawing = ( |
| 119 |
db: Database, |
| 120 |
who: Who, |
| 121 |
input: { prompt: string; kind?: string }, |
| 122 |
): Result<Drawing> => |
| 123 |
audited(db, who, "drawing", "create", undefined, start(db, who, input)); |
| 124 |
|
| 125 |
function start( |
| 126 |
db: Database, |
| 127 |
who: Who, |
| 128 |
input: { prompt: string; kind?: string }, |
| 129 |
): Result<Drawing> { |
| 130 |
if (who.role === "anon" || permit(who, "drawing", "create") !== "yes") |
| 131 |
return refuse(who, "Sign in to draw.", "You may not draw here."); |
| 132 |
const prompt = input.prompt.trim().replace(/\s+/g, " "); |
| 133 |
if (prompt.length === 0 || prompt.length > PROMPT_MAX) |
| 134 |
return fail("invalid", `Describe it in 1 to ${PROMPT_MAX} characters.`); |
| 135 |
const kind = input.kind === undefined || input.kind === "" ? "mark" : input.kind; |
| 136 |
if (!isKind(kind)) |
| 137 |
return fail("invalid", `A kind is one of: ${Object.keys(KINDS).join(", ")}.`); |
| 138 |
const busy = db |
| 139 |
.query<{ mine: number; everyone: number }, [string]>( |
| 140 |
"SELECT count(*) FILTER (WHERE author_id = ?1) AS mine, count(*) AS everyone FROM drawing WHERE status = 'drawing'", |
| 141 |
) |
| 142 |
.get(who.id)!; |
| 143 |
if (busy.mine > 0) |
| 144 |
return fail("busy", "Your last drawing is still on the board. One at a time."); |
| 145 |
if (busy.everyone >= AT_ONCE) |
| 146 |
return fail("busy", "Claude is drawing for others right now. Try again in a minute."); |
| 147 |
const drawing = db |
| 148 |
.query<Drawing, [string, string, string, string]>( |
| 149 |
"INSERT INTO drawing (prompt, kind, author_id, author_name) VALUES (?1, ?2, ?3, ?4) RETURNING *", |
| 150 |
) |
| 151 |
.get(prompt, kind, who.id, who.name)!; |
| 152 |
emit("drawing.added", { id: drawing.id, status: drawing.status }); |
| 153 |
kick(db, drawing.id, prompt, kind); |
| 154 |
return ok(drawing); |
| 155 |
} |
| 156 |
|
| 157 |
|
| 158 |
|
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
function drawingFor( |
| 163 |
db: Database, |
| 164 |
who: Who, |
| 165 |
verb: "read" | "delete", |
| 166 |
id: number, |
| 167 |
said: { signIn: string; no: string }, |
| 168 |
): Result<Drawing> { |
| 169 |
const drawing = Number.isInteger(id) |
| 170 |
? db.query<Drawing, [number]>("SELECT * FROM drawing WHERE id = ?1").get(id) |
| 171 |
: null; |
| 172 |
const row: Readonly<Record<string, unknown>> = |
| 173 |
drawing === null ? { author_id: who.role === "anon" ? null : who.id } : { ...drawing }; |
| 174 |
if (permit(who, "drawing", verb, row) !== "yes") return refuse(who, said.signIn, said.no); |
| 175 |
if (drawing !== null) return ok(drawing); |
| 176 |
if (!Number.isInteger(id)) return fail("invalid", "A drawing id is a whole number."); |
| 177 |
return fail("not_found", "No such drawing."); |
| 178 |
} |
| 179 |
|
| 180 |
export function getDrawing(db: Database, who: Who, input: { id: number }): Result<Drawing> { |
| 181 |
return drawingFor(db, who, "read", input.id, { |
| 182 |
signIn: "Sign in to see this drawing.", |
| 183 |
no: "Only whoever asked for a drawing can see it.", |
| 184 |
}); |
| 185 |
} |
| 186 |
|
| 187 |
export const deleteDrawing = ( |
| 188 |
db: Database, |
| 189 |
who: Who, |
| 190 |
input: { id: number }, |
| 191 |
): Result<{ id: number }> => |
| 192 |
audited(db, who, "drawing", "delete", input.id, remove(db, who, input)); |
| 193 |
|
| 194 |
function remove(db: Database, who: Who, input: { id: number }): Result<{ id: number }> { |
| 195 |
const found = drawingFor(db, who, "delete", input.id, { |
| 196 |
signIn: "Sign in to remove a drawing.", |
| 197 |
no: "Only whoever asked for a drawing, or an admin, can remove it.", |
| 198 |
}); |
| 199 |
if (!found.ok) return found; |
| 200 |
db.query("DELETE FROM drawing WHERE id = ?1").run(found.value.id); |
| 201 |
emit("drawing.removed", { id: found.value.id }); |
| 202 |
return ok({ id: found.value.id }); |
| 203 |
} |
| 204 |
|
| 205 |
|
| 206 |
export const canDelete = (who: Who, drawing: Drawing): boolean => |
| 207 |
permit(who, "drawing", "delete", { ...drawing }) === "yes"; |
| 208 |
|
| 209 |
|
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
|
| 214 |
|
| 215 |
export function listAudit( |
| 216 |
db: Database, |
| 217 |
who: Who, |
| 218 |
input: { limit?: number; before?: number } = {}, |
| 219 |
): Result<AuditRow[]> { |
| 220 |
if (permit(who, "audit", "list") !== "yes") |
| 221 |
return refuse( |
| 222 |
who, |
| 223 |
"Sign in to read the audit trail.", |
| 224 |
"Only an admin can read the audit trail.", |
| 225 |
); |
| 226 |
const limit = Math.min( |
| 227 |
Math.max(Math.trunc(input.limit ?? 100) || 100, 1), |
| 228 |
500, |
| 229 |
); |
| 230 |
const before = Number.isInteger(input.before) |
| 231 |
? input.before! |
| 232 |
: Number.MAX_SAFE_INTEGER; |
| 233 |
return ok( |
| 234 |
db |
| 235 |
.query< |
| 236 |
AuditRow, |
| 237 |
[number, number] |
| 238 |
>("SELECT * FROM audit WHERE id < ?1 ORDER BY id DESC LIMIT ?2") |
| 239 |
.all(before, limit), |
| 240 |
); |
| 241 |
} |
| 242 |
|
| 243 |
export function getAudit( |
| 244 |
db: Database, |
| 245 |
who: Who, |
| 246 |
input: { id: number }, |
| 247 |
): Result<AuditRow> { |
| 248 |
const row = Number.isInteger(input.id) |
| 249 |
? db |
| 250 |
.query<AuditRow, [number]>("SELECT * FROM audit WHERE id = ?1") |
| 251 |
.get(input.id) |
| 252 |
: null; |
| 253 |
if (permit(who, "audit", "read", { ...row }) !== "yes") |
| 254 |
return refuse( |
| 255 |
who, |
| 256 |
"Sign in to read the audit trail.", |
| 257 |
"Only an admin can read the audit trail.", |
| 258 |
); |
| 259 |
if (row !== null) return ok(row); |
| 260 |
if (!Number.isInteger(input.id)) |
| 261 |
return fail("invalid", "An audit id is a whole number."); |
| 262 |
return fail("not_found", "No such audit row."); |
| 263 |
} |