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:
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 & charges — £500 > 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([]);
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user