Build Cobble project structure from the handoff

- Remove the preset document suite (letters, emails, invoices, quotes,
  PDF generation, line items) with its routes, API handlers and tables
- Rebrand to Cobble: site data, Lato, handoff design tokens (light and
  dark) in main.css, brand assets in public/brand, ember stone logo and
  regenerated favicons
- Add bots, bot_messages, bot_tasks and bot_task_steps tables, regenerate
  DB types (BUN_SQLITE_COBBLE_*) and add a sample crew seed (seed:bots)
- Add the chat shell at /admin/bots: rail with pinned coordinator and
  user popover, bottom-anchored conversation with composer, working-on
  panel, and a send-message API scoped to the session user
- Add BotStone and Wordmark components, crew status and relative time
  utils with tests, and update AGENTS.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 17:10:36 +01:00
co-authored by Claude Opus 5.5
parent 81e4a8aecc
commit 3286b80029
340 changed files with 3161 additions and 12060 deletions
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
import checkUserAccess from "./check-user-access";
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
function makeUserTypes(...types: string[]): BUN_SQLITE_BSQLITE_USER_TYPES[] {
function makeUserTypes(...types: string[]): BUN_SQLITE_COBBLE_USER_TYPES[] {
return types.map((user_type) => ({
user_id: 1,
user_type,
@@ -1,11 +1,11 @@
import type { BUN_SQLITE_BSQLITE_USER_TYPES } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { UserType } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
includes?: UserType[];
excludes?: UserType[];
user_types?: BUN_SQLITE_BSQLITE_USER_TYPES[] | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
};
export default function checkUserAccess({
@@ -1,7 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USERS,
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_USERS,
BUN_SQLITE_COBBLE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import AppError from "@/src/utils/app-error";
@@ -24,7 +24,7 @@ export default async function createSignupUser({
username,
}: Params) {
const new_user_res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USERS,
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
@@ -50,7 +50,7 @@ export default async function createSignupUser({
}
const type_res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
@@ -1,6 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
@@ -10,7 +10,7 @@ type Params = {
export default async function deleteUserSSOCodes({ user_id }: Params) {
return await BunSQLite.delete<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
type Params = {
identifier: string;
@@ -11,7 +11,7 @@ type Params = {
export default async function findUserByIdentifier({ identifier }: Params) {
return (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USERS,
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
@@ -1,6 +1,6 @@
import { randomInt } from "node:crypto";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_USERS, BunSQLiteTables } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS, BunSQLiteTables } from "@/db/types/db";
import AppError from "@/src/utils/app-error";
type Params = {
@@ -24,7 +24,7 @@ export default async function generateUniqueUsername({ email }: Params) {
const existing = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USERS,
BUN_SQLITE_COBBLE_USERS,
(typeof BunSQLiteTables)[number]
>({
table: "users",
@@ -1,5 +1,5 @@
import type {
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import type { UserAuthReturn } from "@/src/types";
@@ -16,7 +16,7 @@ export default async function grabUserAuthTypes({
const user_types =
(
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
@@ -1,6 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BUN_SQLITE_COBBLE_USER_PASSWORDS,
BunSQLiteTables,
} from "@/db/types/db";
@@ -15,7 +15,7 @@ type Params = {
export default async function grabUserPasswordHash({ user_id }: Params) {
const record = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BUN_SQLITE_COBBLE_USER_PASSWORDS,
(typeof BunSQLiteTables)[number]
>({
table: "user_passwords",
@@ -13,7 +13,7 @@ import type {
BunextAPIRouteHandlerObjectReturn,
} from "@moduletrace/bunext/types";
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
@@ -37,7 +37,7 @@ export default async function issueSSOLoginCode({
const sso_code = randomInt(0, 1000000).toString().padStart(6, "0");
const record_sso = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
data: [
+4 -4
View File
@@ -4,8 +4,8 @@ import { encrypt } from "@/src/utils/crypt";
import grabUsers from "../db/users/grab-users";
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import generateCSRF from "./gen-csrf";
@@ -44,7 +44,7 @@ export default async function loginUser({
const target_user_types = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
@@ -65,7 +65,7 @@ export default async function loginUser({
const profile_media = target_user.profile_media_id
? (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA,
(typeof BunSQLiteTables)[number]
>({
table: "media",
@@ -1,6 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BUN_SQLITE_COBBLE_USER_PASSWORDS,
BunSQLiteTables,
} from "@/db/types/db";
import hashPassword from "./hash-password";
@@ -21,7 +21,7 @@ export default async function setUserPassword({ user_id, password }: Params) {
const password_hash = await hashPassword({ password });
const res = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_USER_PASSWORDS,
BUN_SQLITE_COBBLE_USER_PASSWORDS,
(typeof BunSQLiteTables)[number]
>({
table: "user_passwords",
@@ -1,5 +1,5 @@
import type {
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
@@ -70,7 +70,7 @@ export default async function verifySSOCode({
const target_sso = (
await BunSQLite.select<
BUN_SQLITE_BSQLITE_SSO_LOGIN_CODES,
BUN_SQLITE_COBBLE_SSO_LOGIN_CODES,
(typeof BunSQLiteTables)[number]
>({
table: "sso_login_codes",
@@ -0,0 +1,35 @@
import type {
BUN_SQLITE_COBBLE_BOT_TASK_STEPS,
BUN_SQLITE_COBBLE_BOT_TASKS,
} from "@/db/types/db";
import type { BotTaskWithSteps } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
};
/**
* The bot's most recent active task and its steps, for the working-on panel.
*/
export default async function grabActiveBotTask({
bot_id,
}: Params): Promise<BotTaskWithSteps | null> {
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active' ORDER BY created_at DESC, id DESC LIMIT 1`,
values: [bot_id],
});
const task = task_res.singleRes || task_res.payload?.[0];
if (!task?.id) {
return null;
}
const steps_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASK_STEPS>({
sql: `SELECT * FROM bot_task_steps WHERE task_id = ? ORDER BY position ASC, id ASC`,
values: [task.id],
});
return { ...task, steps: steps_res.payload || [] };
}
@@ -0,0 +1,31 @@
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
limit?: number;
};
/**
* The latest messages of a bot's conversation, oldest first.
*/
export default async function grabBotMessages({
bot_id,
limit = 100,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES[]> {
let sql = ``;
sql += `SELECT * FROM (\n`;
sql += ` SELECT * FROM bot_messages\n`;
sql += ` WHERE bot_id = ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` LIMIT ?\n`;
sql += `) ORDER BY created_at ASC, id ASC\n`;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql,
values: [bot_id, limit],
});
return res.payload || [];
}
@@ -0,0 +1,39 @@
import type { BotWithLastMessage } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
};
/**
* A workspace's crew for the rail: the coordinator pinned first, then
* workers by latest activity. Each row carries its latest message.
*/
export default async function grabBots({
user_id,
}: Params): Promise<BotWithLastMessage[]> {
let sql = ``;
sql += `SELECT\n`;
sql += ` b.*,\n`;
sql += ` lm.text AS last_message_text,\n`;
sql += ` lm.created_at AS last_message_at\n`;
sql += `FROM bots b\n`;
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
sql += ` SELECT id FROM bot_messages\n`;
sql += ` WHERE bot_id = b.id\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` LIMIT 1\n`;
sql += `)\n`;
sql += `WHERE b.user_id = ?\n`;
sql += `ORDER BY\n`;
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
sql += ` COALESCE(lm.created_at, b.last_activity_at, b.created_at) DESC\n`;
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [user_id],
});
return res.payload || [];
}
@@ -0,0 +1,22 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* A bot only if it belongs to the given workspace owner.
*/
export default async function grabUserBot({
bot_id,
user_id,
}: Params): Promise<BUN_SQLITE_COBBLE_BOTS | null> {
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
});
return res.payload?.[0] || null;
}
@@ -0,0 +1,50 @@
import type {
BUN_SQLITE_COBBLE_BOT_MESSAGES,
BunSQLiteTables,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number;
text: string;
};
/**
* Appends a user message to a bot's conversation and bumps its activity.
* Callers must check the bot belongs to the session user first.
*/
export default async function insertUserMessage({
bot_id,
text,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
const now = Date.now();
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOT_MESSAGES,
(typeof BunSQLiteTables)[number]
>({
table: "bot_messages",
data: [{ bot_id, author: "user", text, needs_you: 0 }],
});
const id = res.postInsertReturn?.insertId;
if (!res.success || !id) {
throw new Error(`Failed to save message: ${res.msg}`);
}
await BunSQLite.sql({
sql: `UPDATE bots SET last_activity_at = ? WHERE id = ?`,
values: [now, bot_id],
});
return {
id,
bot_id,
author: "user",
text,
needs_you: 0,
created_at: now,
updated_at: now,
};
}
@@ -0,0 +1,16 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* Clears a bot's unread flag when its owner opens the conversation.
*/
export default async function markBotRead({ bot_id, user_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bots SET unread = 0 WHERE id = ? AND user_id = ? AND unread = 1`,
values: [bot_id, user_id],
});
}
@@ -1,4 +1,4 @@
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
import type { TableType } from "@/src/types";
import uniqueStringArr from "@/src/utils/unique-string-arr";
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
@@ -15,11 +15,11 @@ type Params = {
/**
* Allowed fields from joins
*/
include_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
include_fields?: (keyof BUN_SQLITE_COBBLE_ALL_TYPEDEFS)[];
/**
* Fields to exclude from joins
*/
exclude_fields?: (keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS)[];
exclude_fields?: (keyof BUN_SQLITE_COBBLE_ALL_TYPEDEFS)[];
};
export default function checkAllowedJoins({
@@ -53,7 +53,7 @@ export default function checkAllowedJoins({
for (let i = 0; i < all_joins.length; i++) {
const join = all_joins[i];
if (!join) continue;
const join_field = join.field as keyof BUN_SQLITE_BSQLITE_ALL_TYPEDEFS;
const join_field = join.field as keyof BUN_SQLITE_COBBLE_ALL_TYPEDEFS;
if (!include_fields.includes(join_field)) {
return {
success: false,
@@ -1,41 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS } from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export type EmailAttachmentRecord = BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS & {
letter_title?: string;
invoice_number?: string;
invoice_title?: string;
quote_number?: string;
quote_title?: string;
media_write_path?: string;
media_mime_type?: string;
};
export default async function grabEmailAttachments({
email_id,
}: {
email_id: number | string;
}): Promise<APIResponseObject<EmailAttachmentRecord>> {
return await BunSQLite.sql<EmailAttachmentRecord>({
sql: `
SELECT
ea.*,
l.title AS letter_title,
i.invoice_number,
i.title AS invoice_title,
q.quote_number,
q.title AS quote_title,
m.media_write_path,
m.mime_type AS media_mime_type
FROM email_attachments ea
LEFT JOIN letters l ON l.id = ea.letter_id
LEFT JOIN invoices i ON i.id = ea.invoice_id
LEFT JOIN quotes q ON q.id = ea.quote_id
LEFT JOIN media m ON m.id = ea.media_id
WHERE ea.email_id = ?
ORDER BY ea.id ASC
`,
values: [email_id],
});
}
@@ -1,14 +0,0 @@
import type { BUN_SQLITE_BSQLITE_EMAILS } from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabEmail({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_EMAILS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_EMAILS>({
table: "emails",
targetId: id,
});
}
@@ -1,14 +0,0 @@
import type { BUN_SQLITE_BSQLITE_LETTERS } from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabLetter({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_LETTERS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_LETTERS>({
table: "letters",
targetId: id,
});
}
@@ -1,102 +0,0 @@
import { Database } from "bun:sqlite";
import { beforeEach, describe, expect, test } from "bun:test";
import grabNextDocumentSequence, {
type DocumentNumberSqlRunner,
} from "./grab-next-document-sequence";
let db: Database;
let run_sql: DocumentNumberSqlRunner;
beforeEach(() => {
db = new Database(":memory:");
db.run("CREATE TABLE invoices (invoice_number TEXT UNIQUE)");
run_sql = async ({ sql, values }) => {
const row = db.query(sql).get(...values) as
| { max_seq: number | null }
| null;
return { singleRes: row };
};
});
function insertNumbers(numbers: string[]) {
const insert = db.prepare("INSERT INTO invoices (invoice_number) VALUES (?)");
for (const number of numbers) {
insert.run(number);
}
}
describe("grabNextDocumentSequence", () => {
test("starts at 1 for an empty table", async () => {
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(1);
});
test("returns max + 1 for existing numbers", async () => {
insertNumbers([
"BSQLITE-202610-001",
"BSQLITE-202610-002",
"BSQLITE-202610-003",
]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(4);
});
test("does not reuse a number after a deletion", async () => {
insertNumbers(["BSQLITE-202610-001", "BSQLITE-202610-003"]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(4);
});
test("ignores numbers from other prefixes", async () => {
insertNumbers([
"BSQLITE-202609-099",
"BSQLITEQ-202610-050",
"BSQLITE-202610-002",
]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(3);
});
test("handles sequences beyond three digits", async () => {
insertNumbers(["BSQLITE-202610-009", "BSQLITE-202610-010"]);
const next = await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix: "BSQLITE-202610",
run_sql,
});
expect(next).toBe(11);
});
});
@@ -1,41 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type DocumentNumberTarget = {
table: "invoices" | "quotes";
column: "invoice_number" | "quote_number";
};
export type DocumentNumberSqlRunner = (args: {
sql: string;
values: (string | number)[];
}) => Promise<{ singleRes?: { max_seq?: number | string | null } | null }>;
type Params = DocumentNumberTarget & {
prefix: string;
/**
* Injectable query runner, used by tests to target an in-memory DB.
*/
run_sql?: DocumentNumberSqlRunner;
};
/**
* # Grab the next document sequence.
* Reads the highest existing numeric suffix for the prefix instead of
* counting rows, so deleting a document can never hand out a number that
* already exists.
*/
export default async function grabNextDocumentSequence({
table,
column,
prefix,
run_sql,
}: Params): Promise<number> {
const sql = `SELECT MAX(CAST(SUBSTR(${column}, ?) AS INTEGER)) AS max_seq FROM ${table} WHERE ${column} LIKE ?`;
const values = [prefix.length + 2, `${prefix}-%`];
const res = run_sql
? await run_sql({ sql, values })
: await BunSQLite.sql({ sql, values });
return Number(res.singleRes?.max_seq || 0) + 1;
}
@@ -1,64 +0,0 @@
import type {
BUN_SQLITE_BSQLITE_INVOICE_ITEMS,
BUN_SQLITE_BSQLITE_INVOICE_QUOTES,
BUN_SQLITE_BSQLITE_INVOICES,
} from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabInvoice({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICES>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICES>({
table: "invoices",
targetId: id,
});
}
export async function grabInvoiceItems({
invoice_id,
}: {
invoice_id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_INVOICE_ITEMS>({
table: "invoice_items",
query: {
query: {
invoice_id: {
value: Number(invoice_id),
},
},
order: [
{ field: "sort_order", strategy: "ASC" },
{ field: "id", strategy: "ASC" },
],
},
});
}
export type InvoiceQuoteLinkRecord = BUN_SQLITE_BSQLITE_INVOICE_QUOTES & {
quote_number?: string;
quote_title?: string;
};
export async function grabInvoiceQuoteLinks({
invoice_id,
}: {
invoice_id: number | string;
}): Promise<APIResponseObject<InvoiceQuoteLinkRecord>> {
return await BunSQLite.sql<InvoiceQuoteLinkRecord>({
sql: `
SELECT
iq.*,
q.quote_number,
q.title AS quote_title
FROM invoice_quotes iq
LEFT JOIN quotes q ON q.id = iq.quote_id
WHERE iq.invoice_id = ?
ORDER BY iq.id ASC
`,
values: [invoice_id],
});
}
@@ -1,15 +1,15 @@
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_MEDIA_JOIN } from "@/src/types/sql-joins";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import _ from "lodash";
import grabUsers from "../users/grab-users";
type Params = {
media_res: APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>;
media_res: APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_JOIN>;
};
export default async function appendUsersToMedia({
media_res,
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_JOIN>> {
try {
let new_media_res = _.cloneDeep(media_res);
@@ -1,5 +1,5 @@
import _ from "lodash";
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import grabMediaFromParadigm from "@/src/functions/backend/db/media/grab-media-from-paraidigm";
import type { MediaParadigmType, TableType, User } from "@/src/types";
import grabDirNames from "@/src/utils/grab-dir-names";
@@ -55,7 +55,7 @@ export default async function deleteMedia(params: Params) {
const media_id_to_delete = media_ids_to_del[i];
const target_media = (
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
await BunSQLite.select<BUN_SQLITE_COBBLE_MEDIA>({
table: "media",
targetId: media_id_to_delete,
})
@@ -77,7 +77,7 @@ export default async function deleteMedia(params: Params) {
const media_id = media_to_delete_array[i];
const target_media = (
await BunSQLite.select<BUN_SQLITE_BSQLITE_MEDIA>({
await BunSQLite.select<BUN_SQLITE_COBBLE_MEDIA>({
table: "media",
targetId: media_id,
})
@@ -101,7 +101,7 @@ export default async function deleteMedia(params: Params) {
);
}
await BunSQLite.delete<BUN_SQLITE_BSQLITE_MEDIA, TableType>({
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
table: "media",
targetId: target_media.id,
});
@@ -1,6 +1,6 @@
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_MEDIA_PARADIGMS } from "@/db/types/db";
import type { MediaParadigmType, TableType } from "@/src/types";
import type { BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_MEDIA_PARADIGMS_JOIN } from "@/src/types/sql-joins";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
@@ -13,10 +13,10 @@ type Params = {
export default async function grabMediaFromParadigm({
media_paradigm,
user_id,
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS_JOIN>> {
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_PARADIGMS_JOIN>> {
try {
const media_paradigms_res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
TableType
>({
table: "media_paradigms",
+5 -5
View File
@@ -1,7 +1,7 @@
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { ApiReqParams, User } from "@/src/types";
import type { BUN_SQLITE_BSQLITE_MEDIA_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_MEDIA_JOIN } from "@/src/types/sql-joins";
import BunSQLite, { type APIResponseObject } from "@moduletrace/bun-sqlite";
import type { SQLInsertGenValueType } from "@moduletrace/bun-sqlite";
import _ from "lodash";
@@ -14,7 +14,7 @@ type Params = {
export default async function grabMedia(
params?: Params,
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA_JOIN>> {
): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA_JOIN>> {
const { query, user } = params || {};
try {
@@ -72,7 +72,7 @@ export default async function grabMedia(
// 4. Parameterized Search Terms
if (query?.search_terms?.length) {
const fields_to_search: (keyof BUN_SQLITE_BSQLITE_MEDIA)[] = [
const fields_to_search: (keyof BUN_SQLITE_COBBLE_MEDIA)[] = [
"title",
"description",
"summary",
@@ -109,7 +109,7 @@ export default async function grabMedia(
const final_sql = `${sql}\n${sql_limit}`;
const final_values = [...values, ...limitValues];
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_MEDIA_JOIN>({
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_MEDIA_JOIN>({
sql: final_sql,
values: final_values,
});
@@ -1,38 +0,0 @@
import type {
BUN_SQLITE_BSQLITE_QUOTE_ITEMS,
BUN_SQLITE_BSQLITE_QUOTES,
} from "@/db/types/db";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
import BunSQLite from "@moduletrace/bun-sqlite";
export default async function grabQuote({
id,
}: {
id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTES>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTES>({
table: "quotes",
targetId: id,
});
}
export async function grabQuoteItems({
quote_id,
}: {
quote_id: number | string;
}): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>> {
return await BunSQLite.select<BUN_SQLITE_BSQLITE_QUOTE_ITEMS>({
table: "quote_items",
query: {
query: {
quote_id: {
value: Number(quote_id),
},
},
order: [
{ field: "sort_order", strategy: "ASC" },
{ field: "id", strategy: "ASC" },
],
},
});
}
@@ -1,5 +1,5 @@
import type {
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_USER_TYPES,
BunSQLiteTables,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
@@ -13,9 +13,9 @@ type Params = {
*/
export default async function grabUserTypes({
user_id,
}: Params): Promise<BUN_SQLITE_BSQLITE_USER_TYPES[]> {
}: Params): Promise<BUN_SQLITE_COBBLE_USER_TYPES[]> {
const res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_USER_TYPES,
BUN_SQLITE_COBBLE_USER_TYPES,
(typeof BunSQLiteTables)[number]
>({
table: "user_types",
+3 -3
View File
@@ -1,6 +1,6 @@
import { AppData } from "@/src/data/app-data";
import type { ApiReqParams, SQLInsertGenValueType } from "@/src/types";
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
@@ -17,7 +17,7 @@ type Params = {
export default async function grabUsers(
params?: Params,
): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_USERS_JOIN>> {
): Promise<APIResponseObject<BUN_SQLITE_COBBLE_USERS_JOIN>> {
const { query } = params || {};
try {
@@ -123,7 +123,7 @@ export default async function grabUsers(
sql_limit += `OFFSET ${offset}\n`;
}
const res = await BunSQLite.sql<BUN_SQLITE_BSQLITE_USERS_JOIN>({
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_USERS_JOIN>({
sql: `${sql}\n${sql_limit}`,
values,
});
@@ -1,7 +1,7 @@
import type {
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
BUN_SQLITE_BSQLITE_USERS,
BUN_SQLITE_COBBLE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
BUN_SQLITE_COBBLE_USERS,
BunSQLiteTables,
} from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
@@ -26,12 +26,12 @@ import deleteMedia from "../../media/delete-media";
type Params = {
media_paradigms?: MediaParadigm[];
media_type?: MediaType;
user: BUN_SQLITE_BSQLITE_USERS | User;
user: BUN_SQLITE_COBBLE_USERS | User;
data?: MediaDataType;
is_primary?: boolean;
update_on_duplicate?: boolean;
extract_media_text_content?: boolean;
media?: BUN_SQLITE_BSQLITE_MEDIA;
media?: BUN_SQLITE_COBBLE_MEDIA;
media_name?: string;
media_mime_type?: string;
is_private?: boolean;
@@ -59,7 +59,7 @@ export default async function uploadAndRecordMedia({
thumbnail_width,
existing_media_id,
media_text_content: passed_media_text_content,
}: Params): Promise<APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA>> {
}: Params): Promise<APIResponseObject<BUN_SQLITE_COBBLE_MEDIA>> {
const now = Date.now();
let target_file_name = `${media_name || now}`;
let target_file_thumbnail_name = `${media_name || now}-thumb`;
@@ -207,7 +207,7 @@ export default async function uploadAndRecordMedia({
if (is_primary) {
const existing_user_primary_media = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA,
TableType
>({
table: "media",
@@ -227,7 +227,7 @@ export default async function uploadAndRecordMedia({
}
}
const media_data: BUN_SQLITE_BSQLITE_MEDIA = {
const media_data: BUN_SQLITE_COBBLE_MEDIA = {
...media,
user_id: Number(user.id),
is_primary: is_primary ? 1 : 0,
@@ -239,11 +239,11 @@ export default async function uploadAndRecordMedia({
text_content: media_text_content,
};
let media_record: BUN_SQLITE_BSQLITE_MEDIA | undefined = undefined;
let media_record: BUN_SQLITE_COBBLE_MEDIA | undefined = undefined;
if (existing_media_id) {
const update_media_record = await BunSQLite.update<
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA,
TableType
>({
data: media_data,
@@ -254,7 +254,7 @@ export default async function uploadAndRecordMedia({
media_record = update_media_record.singleRes || undefined;
} else {
const new_media_record = await BunSQLite.insert<
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA,
TableType
>({
data: [media_data],
@@ -271,7 +271,7 @@ export default async function uploadAndRecordMedia({
if (media_paradigms?.[0] && media_record?.id) {
const delete_existing_paradigms = await BunSQLite.delete<
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
(typeof BunSQLiteTables)[number]
>({
table: "media_paradigms",
@@ -281,7 +281,7 @@ export default async function uploadAndRecordMedia({
});
await BunSQLite.insert<
BUN_SQLITE_BSQLITE_MEDIA_PARADIGMS,
BUN_SQLITE_COBBLE_MEDIA_PARADIGMS,
(typeof BunSQLiteTables)[number]
>({
data: media_paradigms.map((mp) => ({
@@ -1,351 +0,0 @@
import { formatInvoiceDate } from "@/src/functions/backend/pdf/letter-date";
import { CompanyLetterheadDefaults } from "@/src/dict/company-dict";
import { formatNaira } from "@/src/utils/money";
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
const letter_divider = (
<hr
style={{
border: "none",
borderTop: `1px solid ${ThemeColors.hairline}`,
}}
/>
);
type InvoiceEmailContentProps = {
invoice: {
invoice_number?: string;
client_name?: string;
issue_date?: number | "";
due_date?: number | "";
status?: string;
notes?: string;
};
items: {
description?: string;
quantity?: number | string | "";
unit_price_kobo?: number | string | "";
}[];
};
type QuoteEmailContentProps = {
quote: {
quote_number?: string;
client_name?: string;
issue_date?: number | "";
valid_until?: number | "";
status?: string;
notes?: string;
};
items: {
description?: string;
quantity?: number | string | "";
unit_price_kobo?: number | string | "";
}[];
};
export function InvoiceEmailContent({
invoice,
items,
}: InvoiceEmailContentProps) {
const total_kobo = items.reduce((acc, item) => {
return (
acc +
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
);
}, 0);
return (
<div>
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: ThemeColors.ink,
}}
>
{CompanyLetterheadDefaults.name}
</div>
{letter_divider}
<h1
style={{
fontSize: "18px",
margin: "0 0 6px",
}}
>
Invoice {invoice.invoice_number || ""}
</h1>
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Hello {invoice.client_name || "there"},
<br />
Please find attached invoice {invoice.invoice_number || ""} for
your records.
</div>
{letter_divider}
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
style={{ fontSize: "13px", borderCollapse: "collapse" }}
>
<tr
style={{
background: ThemeColors.ink,
color: ThemeColors.surface,
fontWeight: "600",
}}
>
<td style={{ padding: "8px 10px" }}>Description</td>
<td align="right" style={{ padding: "8px 10px" }}>
Qty
</td>
<td align="right" style={{ padding: "8px 10px" }}>
Amount
</td>
</tr>
{items.map((item, index) => (
<tr
key={index}
style={
index % 2 == 0
? { background: ThemeColors.faint }
: undefined
}
>
<td style={{ padding: "7px 10px" }}>
{item.description}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{Number(item.quantity) || 0}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{formatNaira(
(Number(item.quantity) || 0) *
(Number(item.unit_price_kobo) || 0),
)}
</td>
</tr>
))}
<tr>
<td
colSpan={2}
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
Total (NGN)
</td>
<td
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
{formatNaira(total_kobo)}
</td>
</tr>
</table>
</div>
{letter_divider}
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Invoice {invoice.invoice_number || ""} was issued on{" "}
{formatInvoiceDate(invoice.issue_date || Date.now())}
{invoice.due_date
? ` and is due by ${formatInvoiceDate(invoice.due_date)}`
: ""}
.
<br />
<br />
Thank you for your business.
</div>
<div
style={{
fontSize: "12px",
color: ThemeColors.muted,
borderTop: `1px solid ${ThemeColors.hairline}`,
paddingTop: "12px",
}}
>
{CompanyLetterheadDefaults.name} -{" "}
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
{CompanyLetterheadDefaults.phone} |{" "}
{CompanyLetterheadDefaults.email} |{" "}
{CompanyLetterheadDefaults.website}
</div>
</div>
);
}
export function QuoteEmailContent({ quote, items }: QuoteEmailContentProps) {
const total_kobo = items.reduce((acc, item) => {
return (
acc +
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
);
}, 0);
return (
<div>
<div
style={{
fontSize: "13px",
fontWeight: "600",
color: ThemeColors.ink,
}}
>
{CompanyLetterheadDefaults.name}
</div>
{letter_divider}
<h1
style={{
fontSize: "18px",
margin: "0 0 6px",
}}
>
Quotation {quote.quote_number || ""}
</h1>
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Hello {quote.client_name || "there"},
<br />
Please find attached quotation {quote.quote_number || ""} for
our proposed works.
</div>
{letter_divider}
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
<table
width="100%"
cellPadding="0"
cellSpacing="0"
style={{ fontSize: "13px", borderCollapse: "collapse" }}
>
<tr
style={{
background: ThemeColors.ink,
color: ThemeColors.surface,
fontWeight: "600",
}}
>
<td style={{ padding: "8px 10px" }}>Description</td>
<td align="right" style={{ padding: "8px 10px" }}>
Qty
</td>
<td align="right" style={{ padding: "8px 10px" }}>
Amount
</td>
</tr>
{items.map((item, index) => (
<tr
key={index}
style={
index % 2 == 0
? { background: ThemeColors.faint }
: undefined
}
>
<td style={{ padding: "7px 10px" }}>
{item.description}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{Number(item.quantity) || 0}
</td>
<td align="right" style={{ padding: "7px 10px" }}>
{formatNaira(
(Number(item.quantity) || 0) *
(Number(item.unit_price_kobo) || 0),
)}
</td>
</tr>
))}
<tr>
<td
colSpan={2}
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
Total (NGN)
</td>
<td
align="right"
style={{
padding: "9px 10px",
fontWeight: "600",
}}
>
{formatNaira(total_kobo)}
</td>
</tr>
</table>
</div>
{letter_divider}
<div
style={{
fontSize: "13px",
lineHeight: "1.7",
color: ThemeColors.body,
}}
>
Quotation {quote.quote_number || ""} was issued on{" "}
{formatInvoiceDate(quote.issue_date || Date.now())}
{quote.valid_until
? ` and is valid until ${formatInvoiceDate(quote.valid_until)}`
: ""}
.
<br />
<br />
We look forward to working with you.
</div>
<div
style={{
fontSize: "12px",
color: ThemeColors.muted,
borderTop: `1px solid ${ThemeColors.hairline}`,
paddingTop: "12px",
}}
>
{CompanyLetterheadDefaults.name} -{" "}
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
{CompanyLetterheadDefaults.phone} |{" "}
{CompanyLetterheadDefaults.email} |{" "}
{CompanyLetterheadDefaults.website}
</div>
</div>
);
}
@@ -1,9 +1,9 @@
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import type { BUN_SQLITE_BSQLITE_USERS_JOIN } from "@/src/types/sql-joins";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
type Params = {
media?: BUN_SQLITE_BSQLITE_MEDIA;
user?: BUN_SQLITE_BSQLITE_USERS_JOIN;
media?: BUN_SQLITE_COBBLE_MEDIA;
user?: BUN_SQLITE_COBBLE_USERS_JOIN;
};
export default function grabMediaURL({ media: passed_media, user }: Params) {
@@ -1,93 +0,0 @@
import { expect, test } from "bun:test";
import htmlToTextBlocks from "./html-blocks";
test("parses paragraphs and inline styles", () => {
const blocks = htmlToTextBlocks(
`<p>Hello <strong>world</strong> and <em>friends</em>.</p><p>Second <u>underlined</u> paragraph.</p>`,
);
expect(blocks).toHaveLength(2);
expect(blocks[0]!.type).toBe("paragraph");
expect((blocks[0] as any).segments).toEqual([
{ text: "Hello " },
{ text: "world", bold: true },
{ text: " and " },
{ text: "friends", italic: true },
{ text: "." },
]);
expect((blocks[1] as any).segments).toEqual([
{ text: "Second " },
{ text: "underlined", underline: true },
{ text: " paragraph." },
]);
});
test("parses lists with ordering", () => {
const blocks = htmlToTextBlocks(
`<ul><li>One</li><li>Two</li></ul><ol><li>First</li></ol>`,
);
expect(blocks).toHaveLength(3);
expect(blocks[0]!).toEqual({
type: "list-item",
ordered: false,
index: 0,
segments: [{ text: "One" }],
});
expect(blocks[1]!).toEqual({
type: "list-item",
ordered: false,
index: 1,
segments: [{ text: "Two" }],
});
expect(blocks[2]!).toEqual({
type: "list-item",
ordered: true,
index: 0,
segments: [{ text: "First" }],
});
});
test("parses headings, blockquote, hr and br", () => {
const blocks = htmlToTextBlocks(
`<h2>Heading</h2><blockquote>Quoted</blockquote><hr><p>Line one<br>Line two</p>`,
);
expect(blocks[0]!.type).toBe("heading");
expect((blocks[0] as any).level).toBe(2);
expect(blocks[1]!.type).toBe("blockquote");
expect(blocks[2]!).toEqual({ type: "divider" });
// <br> splits into separate paragraphs
expect(blocks).toHaveLength(5);
expect((blocks[3] as any).segments.map((s: any) => s.text).join("")).toBe(
"Line one",
);
expect((blocks[4] as any).segments.map((s: any) => s.text).join("")).toBe(
"Line two",
);
});
test("decodes HTML entities", () => {
const blocks = htmlToTextBlocks(
`<p>Fee &amp; charges &mdash; &#163;500 &gt; 400</p>`,
);
expect((blocks[0] as any).segments[0].text).toBe(
"Fee & charges \u2014 \u00a3500 > 400",
);
});
test("uses img alt text and ignores unknown tags", () => {
const blocks = htmlToTextBlocks(
`<p>See <img src="x.png" alt="the chart" /> below.</p><svg><circle></circle></svg>`,
);
expect((blocks[0] as any).segments.map((s: any) => s.text).join("")).toBe(
"See the chart below.",
);
});
test("returns empty for empty input", () => {
expect(htmlToTextBlocks(undefined)).toEqual([]);
expect(htmlToTextBlocks("")).toEqual([]);
});
-262
View File
@@ -1,262 +0,0 @@
export type TextSegment = {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
};
export type TextBlock =
| { type: "paragraph"; segments: TextSegment[] }
| { type: "heading"; level: number; segments: TextSegment[] }
| { type: "list-item"; ordered: boolean; index: number; segments: TextSegment[] }
| { type: "blockquote"; segments: TextSegment[] }
| { type: "divider" };
const named_entities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ensp: " ",
emsp: " ",
ndash: "\u2013",
mdash: "\u2014",
hellip: "\u2026",
lsquo: "\u2018",
rsquo: "\u2019",
ldquo: "\u201c",
rdquo: "\u201d",
bull: "\u2022",
middot: "\u00b7",
times: "\u00d7",
copy: "\u00a9",
reg: "\u00ae",
trade: "\u2122",
deg: "\u00b0",
sect: "\u00a7",
para: "\u00b6",
euro: "\u20ac",
pound: "\u00a3",
minus: "\u2212",
radic: "\u221a",
frac12: "\u00bd",
frac14: "\u00bc",
frac34: "\u00be",
};
function decodeEntities(text: string): string {
return text
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16)),
)
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
.replace(
/&([a-zA-Z]+);/g,
(match, name: string) => named_entities[name.toLowerCase()] || match,
);
}
const INLINE_STYLE_OPEN = ["b", "strong", "i", "em", "u", "ins"];
const INLINE_STYLE_CLOSE = ["b", "strong", "i", "em", "u", "ins"];
const HEADING_LEVELS: Record<string, number> = {
h1: 1,
h2: 2,
h3: 3,
h4: 4,
h5: 5,
h6: 6,
};
/**
* # Convert an HTML fragment (TinyMCE output) into simple text blocks
* suitable for rendering in a PDF. Supports: p, div, br, h1-h6, ul/ol/li,
* blockquote, hr, b/strong, i/em, u/ins, a, span, img (alt text) and
* basic table cells (degraded to inline text separated by spaces).
*/
export default function htmlToTextBlocks(html?: string): TextBlock[] {
const source = html || "";
const blocks: TextBlock[] = [];
let pending_text = "";
const style = { bold: false, italic: false, underline: false };
let current: TextBlock | undefined;
const list_stack: { ordered: boolean; index: number }[] = [];
const appendText = (raw: string) => {
if (!raw) return;
const decoded = decodeEntities(raw);
if (!decoded) return;
pending_text += decoded;
};
const flushText = () => {
if (!pending_text) return;
if (!current) {
current = { type: "paragraph", segments: [] };
} else if (current.type == "divider") {
current = { type: "paragraph", segments: [] };
}
current.segments.push({
text: pending_text,
...(style.bold ? { bold: true } : {}),
...(style.italic ? { italic: true } : {}),
...(style.underline ? { underline: true } : {}),
});
pending_text = "";
};
const flushBlock = () => {
flushText();
if (current) {
blocks.push(current);
current = undefined;
}
};
const startBlock = (block: TextBlock) => {
flushBlock();
current = block;
};
const pattern = /<(\/?)([a-zA-Z0-9]+)([^>]*)>/g;
let last = 0;
let match;
while ((match = pattern.exec(source)) !== null) {
appendText(source.slice(last, match.index));
last = match.index + (match[0] || "").length;
const closing = Boolean(match[1]);
const tag = (match[2] || "").toLowerCase();
const attrs = match[3] || "";
if (INLINE_STYLE_OPEN.includes(tag) && !closing) {
if (["b", "strong"].includes(tag)) {
flushText();
style.bold = true;
}
if (["i", "em"].includes(tag)) {
flushText();
style.italic = true;
}
if (["u", "ins"].includes(tag)) {
flushText();
style.underline = true;
}
continue;
}
if (INLINE_STYLE_CLOSE.includes(tag) && closing) {
if (["b", "strong"].includes(tag)) {
flushText();
style.bold = false;
}
if (["i", "em"].includes(tag)) {
flushText();
style.italic = false;
}
if (["u", "ins"].includes(tag)) {
flushText();
style.underline = false;
}
continue;
}
if (["p", "div"].includes(tag)) {
if (closing) {
flushBlock();
} else {
startBlock({ type: "paragraph", segments: [] });
}
continue;
}
if (HEADING_LEVELS[tag]) {
if (closing) {
flushBlock();
} else {
startBlock({
type: "heading",
level: HEADING_LEVELS[tag],
segments: [],
});
}
continue;
}
if (tag == "br") {
flushBlock();
continue;
}
if (tag == "hr") {
flushBlock();
blocks.push({ type: "divider" });
continue;
}
if (["ul", "ol"].includes(tag)) {
if (closing) {
flushBlock();
list_stack.pop();
} else {
flushBlock();
list_stack.push({ ordered: tag == "ol", index: 0 });
}
continue;
}
if (tag == "li") {
if (closing) {
flushBlock();
} else {
const list = list_stack[list_stack.length - 1] || {
ordered: false,
index: 0,
};
startBlock({
type: "list-item",
ordered: list.ordered,
index: list.index,
segments: [],
});
list.index += 1;
}
continue;
}
if (tag == "blockquote") {
if (closing) {
flushBlock();
} else {
startBlock({ type: "blockquote", segments: [] });
}
continue;
}
if (tag == "img" && !closing) {
const alt = attrs.match(/alt\s*=\s*["']([^"']*)["']/i);
if (alt?.[1]) appendText(` ${alt[1]} `);
continue;
}
if (["td", "th", "p", "div", "li"].includes(tag) && !closing) {
appendText(" ");
continue;
}
if (["table", "thead", "tbody", "tr"].includes(tag)) {
if (!closing && ["tr"].includes(tag)) {
// no-op, cells add their own spacing
}
continue;
}
}
appendText(source.slice(last));
flushBlock();
return blocks;
}
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
import dayjs from "dayjs";
function ordinal(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 13) {
return "th";
}
switch (n % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
/**
* # Format a timestamp as a letter date, e.g. "22nd September, 2026".
*/
export function formatLetterDate(timestamp: number): string {
const date = dayjs(timestamp);
return `${date.date()}${ordinal(date.date())} ${date.format("MMMM, YYYY")}`;
}
/**
* # Format a timestamp as a short invoice date, e.g. "22 Sep 2026".
*/
export function formatInvoiceDate(
timestamp?: number | string | "",
): string {
if (!timestamp) {
return "\u2014";
}
return dayjs(Number(timestamp)).format("DD MMM YYYY");
}
-233
View File
@@ -1,233 +0,0 @@
import { PDFDocument } from "pdf-lib";
import {
CompanyLetterheadDefaults,
type CompanyLetterhead,
} from "@/src/dict/company-dict";
import {
PdfColors,
PdfMargin,
PdfPageHeight,
PdfPageWidth,
drawBrandFooterOnAllPages,
drawBrandHeader,
drawTextAt,
embedBrandLogo,
embedPdfFonts,
type PdfFonts,
} from "./pdf-helpers";
import htmlToTextBlocks from "./html-blocks";
import { formatLetterDate } from "./letter-date";
import {
ensureSpace,
renderBlocks,
type RenderContext,
} from "./render-blocks";
type TemplateOverview = {
title?: string;
to_name?: string;
to_address?: string;
from_name?: string;
from_address?: string;
salutation?: string;
body_html?: string;
closing?: string;
signature_name?: string;
signature_title?: string;
};
export type LetterPdfParams = {
template: TemplateOverview;
/** Overrides the template's default recipient at send-time. */
to_name?: string;
to_address?: string;
/** Unix timestamp in ms. Defaults to now. */
letter_date?: number;
company?: CompanyLetterhead;
};
const BODY_SIZE = 11;
const BODY_LINE_GAP = 15.5;
export default async function generateLetterPdf({
template,
to_name,
to_address,
letter_date,
company,
}: LetterPdfParams): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.create();
const fonts = await embedPdfFonts(pdfDoc);
const logo = await embedBrandLogo(pdfDoc);
const company_block = {
...CompanyLetterheadDefaults,
...(company || {}),
};
let page = pdfDoc.addPage([PdfPageWidth, PdfPageHeight]);
const ctx: RenderContext = {
pdfDoc,
page,
fonts,
y: drawBrandHeader({
page,
fonts,
options: { logo, ...company_block },
}),
};
const from_name = template.from_name?.trim();
const from_address = template.from_address?.trim();
if (from_name || from_address) {
ensureSpace(ctx, 80);
ctx.y += 6;
drawTextAt(ctx.page, "From:", {
x: PdfMargin,
y_from_top: ctx.y,
size: 9,
font: fonts.bold,
color: PdfColors.darkGray,
});
ctx.y += 14;
if (from_name) {
drawTextAt(ctx.page, from_name, {
x: PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.bold,
color: PdfColors.black,
});
ctx.y += 13.5;
}
if (from_address) {
for (const line of from_address.split(/\r?\n/)) {
drawTextAt(ctx.page, line, {
x: PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += 13;
}
}
ctx.y += 10;
}
const date_text = formatLetterDate(letter_date || Date.now());
drawTextAt(ctx.page, date_text, {
x: PdfPageWidth - PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.regular,
color: PdfColors.black,
align: "right",
});
ctx.y += 24;
const final_to_name = (to_name || template.to_name || "").trim();
const final_to_address = (to_address || template.to_address || "")
.trim();
if (final_to_name) {
drawTextAt(ctx.page, final_to_name, {
x: PdfMargin,
y_from_top: ctx.y,
size: 11,
font: fonts.bold,
color: PdfColors.black,
});
ctx.y += 14.5;
}
if (final_to_address) {
for (const line of final_to_address.split(/\r?\n/)) {
drawTextAt(ctx.page, line, {
x: PdfMargin,
y_from_top: ctx.y,
size: 10.5,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += 13;
}
}
ctx.y += 18;
const salutation = template.salutation?.trim();
if (salutation) {
drawTextAt(ctx.page, salutation, {
x: PdfMargin,
y_from_top: ctx.y,
size: BODY_SIZE,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += BODY_LINE_GAP + 6;
}
const blocks = htmlToTextBlocks(template.body_html);
if (blocks[0]) {
renderBlocks({ blocks, fonts, ctx });
}
const closing = template.closing?.trim();
const signature_name = template.signature_name?.trim();
const signature_title = template.signature_title?.trim();
if (closing || signature_name) {
ensureSpace(ctx, 120);
ctx.y += 28;
if (closing) {
drawTextAt(ctx.page, closing, {
x: PdfMargin,
y_from_top: ctx.y,
size: BODY_SIZE,
font: fonts.regular,
color: PdfColors.black,
});
ctx.y += 30;
}
if (signature_name) {
drawTextAt(ctx.page, signature_name, {
x: PdfMargin,
y_from_top: ctx.y,
size: 11.5,
font: fonts.bold,
color: PdfColors.black,
});
ctx.y += 15;
}
if (signature_title) {
drawTextAt(ctx.page, signature_title, {
x: PdfMargin,
y_from_top: ctx.y,
size: 9.5,
font: fonts.regular,
color: PdfColors.gray,
});
ctx.y += 13;
}
}
drawBrandFooterOnAllPages({
pdfDoc,
fonts,
options: { ...company_block },
});
return await pdfDoc.save();
}
File diff suppressed because it is too large Load Diff
-257
View File
@@ -1,257 +0,0 @@
import { PDFDocument, type PDFPage } from "pdf-lib";
import {
PdfColors,
PdfMargin,
PdfPageHeight,
PdfPageWidth,
drawBrandHeader,
drawStyledLine,
drawTextAt,
wrapStyledSegments,
type PdfColor,
type PdfFonts,
type StyledLine,
} from "./pdf-helpers";
import type { TextBlock } from "./html-blocks";
const FOOTER_SPACE = 140;
const BODY_AVAILABLE_WIDTH = PdfPageWidth - PdfMargin * 2;
export type RenderContext = {
pdfDoc: PDFDocument;
page: PDFPage;
y: number;
fonts: PdfFonts;
};
export type BlockRenderStyle = {
/** Body font size in pt. */
size: number;
/** Vertical gap between lines within a block. */
line_gap: number;
/** Body text color. */
color: PdfColor;
blockquote_size: number;
blockquote_indent: number;
/** Extra space after a paragraph/blockquote block. */
paragraph_after: number;
/** Space before a heading block. */
heading_level_gap: number;
/** Extra space after a heading block. */
heading_after: number;
/** Extra space after a list item. */
list_after: number;
divider_before: number;
divider_after: number;
};
const DefaultBlockStyle: BlockRenderStyle = {
size: 11,
line_gap: 12.5,
color: PdfColors.black,
blockquote_size: 10,
blockquote_indent: 26,
paragraph_after: 7,
heading_level_gap: 8,
heading_after: 6,
list_after: 4,
divider_before: 8,
divider_after: 14,
};
/**
* # Compact block style for tight content like invoice/quote notes.
* Mirrors the ACCOUNT DETAILS section: small font, subdued color,
* minimal margins.
*/
export const NotesBlockStyle: BlockRenderStyle = {
size: 9.5,
line_gap: 12.5,
color: PdfColors.darkGray,
blockquote_size: 9,
blockquote_indent: 16,
paragraph_after: 3,
heading_level_gap: 5,
heading_after: 4,
list_after: 2,
divider_before: 5,
divider_after: 8,
};
/**
* # Ensure `amount` points of vertical space remain on the current page.
* Starts a fresh page (with letterhead) when there isn't enough room.
*/
export function ensureSpace(ctx: RenderContext, amount: number) {
if (ctx.y + amount <= PdfPageHeight - FOOTER_SPACE) {
return;
}
const page = ctx.pdfDoc.addPage([PdfPageWidth, PdfPageHeight]);
ctx.page = page;
ctx.y = drawBrandHeader({ page, fonts: ctx.fonts });
}
/**
* # Render HTML-derived text blocks onto a PDF document.
* Shared by letter bodies and invoice/quote notes.
*/
export function renderBlocks({
blocks,
fonts,
ctx,
style = DefaultBlockStyle,
}: {
blocks: TextBlock[];
fonts: PdfFonts;
ctx: RenderContext;
style?: BlockRenderStyle;
}): RenderContext {
// Standard WinAnsi fonts cannot encode the ₦ glyph; PDFs use "NGN" instead.
for (const block of blocks) {
if (block.type == "divider") {
continue;
}
for (const segment of block.segments) {
segment.text = segment.text.replace(/\u20a6/g, "NGN ");
}
}
for (const block of blocks) {
switch (block.type) {
case "paragraph":
case "blockquote": {
if (block.segments.length == 0) {
ctx.y += style.line_gap;
continue;
}
const is_blockquote = block.type == "blockquote";
const indent = is_blockquote ? style.blockquote_indent : 0;
const size = is_blockquote ? style.blockquote_size : style.size;
const lines = wrapStyledSegments({
segments: block.segments,
fonts,
size,
max_width: BODY_AVAILABLE_WIDTH - indent,
});
for (const line of lines) {
ensureSpace(ctx, style.line_gap);
drawStyledLine(ctx.page, line, {
x: PdfMargin + indent,
y_from_top: ctx.y,
size,
color: style.color,
});
ctx.y += style.line_gap;
}
ctx.y += style.paragraph_after;
break;
}
case "heading": {
const size = style.size + (block.level <= 2 ? 3 : 1.5);
const lines = wrapStyledSegments({
segments: block.segments,
fonts,
size,
max_width: BODY_AVAILABLE_WIDTH,
});
ensureSpace(ctx, lines.length * (size + 8) + 24);
ctx.y += style.heading_level_gap;
for (const line of lines) {
ensureSpace(ctx, size + 7);
const bold_line: StyledLine = {
words: line.words.map((word) => ({
...word,
font:
word.font == fonts.italic
? fonts.boldItalic
: fonts.bold,
})),
width: line.width,
};
drawStyledLine(ctx.page, bold_line, {
x: PdfMargin,
y_from_top: ctx.y,
size,
color: style.color,
});
ctx.y += size + 7;
}
ctx.y += style.heading_after;
break;
}
case "list-item": {
const marker = block.ordered ? `${block.index + 1}.` : "\u2022";
const marker_text = `${marker} `;
const marker_width = fonts.regular.widthOfTextAtSize(
marker_text,
style.size,
);
const hang_indent = 18;
const indent = PdfMargin + hang_indent;
const lines = wrapStyledSegments({
segments: block.segments,
fonts,
size: style.size,
max_width:
BODY_AVAILABLE_WIDTH - hang_indent - marker_width,
});
for (const [line_index, line] of lines.entries()) {
ensureSpace(ctx, style.line_gap);
if (line_index == 0) {
drawTextAt(ctx.page, marker, {
x: PdfMargin,
y_from_top: ctx.y,
size: style.size,
font: fonts.regular,
color: style.color,
});
}
drawStyledLine(ctx.page, line, {
x: indent + (line_index == 0 ? marker_width : 0),
y_from_top: ctx.y,
size: style.size,
color: style.color,
});
ctx.y += style.line_gap;
}
ctx.y += style.list_after;
break;
}
case "divider": {
ensureSpace(ctx, 30);
ctx.y += style.divider_before;
ctx.page.drawRectangle({
x: PdfMargin,
y: ctx.page.getHeight() - ctx.y - 1,
width: BODY_AVAILABLE_WIDTH,
height: 1,
color: PdfColors.gray,
});
ctx.y += style.divider_after;
break;
}
}
}
return ctx;
}
+2 -2
View File
@@ -1,10 +1,10 @@
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { ApiReqParams, ClientAdminCrudParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function adminCrudHandler<
T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS,
>(params: ClientAdminCrudParams<T>) {
const {
action,
@@ -1,10 +1,10 @@
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_ALL_TYPEDEFS } from "@/db/types/db";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { AdminFetchParams, ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function adminFetchHandler<
T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
T extends {} = BUN_SQLITE_COBBLE_ALL_TYPEDEFS,
>({ url: passed_url, method = "GET", query, body }: AdminFetchParams<T>) {
let url = passed_url;
@@ -1,4 +1,4 @@
import type { BUN_SQLITE_BSQLITE_MEDIA } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db";
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bun-sqlite";
@@ -6,7 +6,7 @@ import type { APIResponseObject } from "@moduletrace/bun-sqlite";
export default async function clientUploadMediaHandler(params: ApiReqParams) {
const res = await fetchApi<
ApiReqParams,
APIResponseObject<BUN_SQLITE_BSQLITE_MEDIA>
APIResponseObject<BUN_SQLITE_COBBLE_MEDIA>
>(`/api/admin/upload-media`, {
method: "POST",
body: params,
-121
View File
@@ -1,121 +0,0 @@
/**
* # Client-side actions for document/invoice generation & emailing.
* PDF endpoints return binary PDFs (not JSON), so plain fetch is used
* here instead of `fetchApi`.
*/
async function postAdminAction({
url,
body,
}: {
url: string;
body: any;
}): Promise<{ success: boolean; msg?: string }> {
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body || {}),
});
const content_type = res.headers.get("Content-Type") || "";
if (content_type.includes("application/json")) {
const json = await res.json();
return {
success: Boolean(json.success),
msg: json.msg,
};
}
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
return {
success: true,
};
} catch (error: any) {
return {
success: false,
msg: error.message || "Request failed",
};
}
}
export async function downloadGeneratedPdf({
url,
body,
fallback_filename,
}: {
url: string;
body: any;
fallback_filename?: string;
}): Promise<{ success: boolean; msg?: string }> {
try {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body || {}),
});
const content_type = res.headers.get("Content-Type") || "";
if (content_type.includes("application/json")) {
const json = await res.json();
return {
success: Boolean(json.success),
msg: json.msg,
};
}
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const blob = await res.blob();
if (blob.size == 0) {
throw new Error(`Received an empty PDF`);
}
const disposition = res.headers.get("Content-Disposition") || "";
const filename_match = disposition.match(/filename="?([^";]+)"?/i);
const filename =
filename_match?.[1] || fallback_filename || "document.pdf";
const object_url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = object_url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
setTimeout(() => {
URL.revokeObjectURL(object_url);
}, 1000);
return { success: true };
} catch (error: any) {
return {
success: false,
msg: error.message || "Couldn't download document",
};
}
}
export async function sendEmailRecord({
url,
body,
}: {
url: string;
body: any;
}) {
return await postAdminAction({ url, body });
}
@@ -1,5 +1,5 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_BSQLITE_MEDIA, BunSQLiteTables } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_MEDIA, BunSQLiteTables } from "@/db/types/db";
import grabDirNames from "@/src/utils/grab-dir-names";
import { existsSync } from "node:fs";
import path from "node:path";
@@ -24,7 +24,7 @@ export default async function handleMediaServer({
throw new Error(`No media privacy params passed`);
}
let media: BUN_SQLITE_BSQLITE_MEDIA | undefined = undefined;
let media: BUN_SQLITE_COBBLE_MEDIA | undefined = undefined;
if (privacy == "private") {
const { user, user_types } = await userAuth({ req });
@@ -34,7 +34,7 @@ export default async function handleMediaServer({
}
const media_res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA,
(typeof BunSQLiteTables)[number]
>({
table: "media",
@@ -51,7 +51,7 @@ export default async function handleMediaServer({
media = media_res.singleRes || undefined;
} else {
const media_res = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA,
(typeof BunSQLiteTables)[number]
>({
table: "media",