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
+63
View File
@@ -0,0 +1,63 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
import { SendBotMessageRequestSchema } from "@/src/schemas/bot-schema";
import parseWithSchema from "@/src/utils/parse-with-schema";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
import AppError from "@/src/utils/app-error";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* Posts a message from the session user to one of their own bots.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
req,
body,
}) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
if (isReadOnlyUser({ user_types: user_types || [] })) {
throw new AppError(`This user can only read records.`);
}
const { bot_id, text } = parseWithSchema({
schema: SendBotMessageRequestSchema,
data: body,
});
const bot = await grabUserBot({ bot_id, user_id: user.id });
if (!bot?.id) {
throw new AppError(`Bot not found`);
}
const message = await insertUserMessage({ bot_id: bot.id, text });
return {
success: true,
singleRes: message,
};
} catch (error: unknown) {
return {
success: false,
msg: safeErrorMessage({ error }),
};
}
};
@@ -1,29 +0,0 @@
import type { AdminCrudAPIParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPost,
gatedCrudPut,
} from "../../(utils)/gated-crud";
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST":
return await gatedCrudPost(params);
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -1,29 +0,0 @@
import type { AdminCrudAPIParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPost,
gatedCrudPut,
} from "../../(utils)/gated-crud";
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST":
return await gatedCrudPost(params);
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -1,29 +0,0 @@
import type { AdminCrudAPIParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPost,
gatedCrudPut,
} from "../../(utils)/gated-crud";
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST":
return await gatedCrudPost(params);
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -1,29 +0,0 @@
import type { AdminCrudAPIParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPost,
gatedCrudPut,
} from "../../(utils)/gated-crud";
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST":
return await gatedCrudPost(params);
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -1,107 +0,0 @@
import type { BUN_SQLITE_BSQLITE_INVOICES } from "@/db/types/db";
import { SiteData } from "@/src/data/site-data";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import grabNextDocumentSequence from "@/src/functions/backend/db/documents/grab-next-document-sequence";
import type { AdminCrudAPIParams } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPut,
} from "../../(utils)/gated-crud";
function invoiceNumberPrefix(): string {
const now = new Date();
return `${SiteData["SiteSlug"].toUpperCase()}-${now.getFullYear()}${String(
now.getMonth() + 1,
).padStart(2, "0")}`;
}
async function buildInvoiceRows({
rows,
}: {
rows: BUN_SQLITE_BSQLITE_INVOICES[];
}): Promise<BUN_SQLITE_BSQLITE_INVOICES[]> {
const prefix = invoiceNumberPrefix();
let next_sequence: number | undefined;
const final_rows: BUN_SQLITE_BSQLITE_INVOICES[] = [];
for (const row of rows) {
const invoice_row = { ...row } as BUN_SQLITE_BSQLITE_INVOICES;
if (!invoice_row.invoice_number) {
next_sequence ??= await grabNextDocumentSequence({
table: "invoices",
column: "invoice_number",
prefix,
});
invoice_row.invoice_number = `${prefix}-${String(next_sequence).padStart(3, "0")}`;
next_sequence += 1;
}
final_rows.push(invoice_row);
}
return final_rows;
}
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST": {
const { table, body, user_types } = params;
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
if (!body?.insert_data) {
return {
success: false,
msg: `No data to insert`,
};
}
for (let attempt = 0; attempt < 5; attempt++) {
const final_insert_data = await buildInvoiceRows({
rows: body.insert_data as BUN_SQLITE_BSQLITE_INVOICES[],
});
try {
return await BunSQLite.insert({
table,
data: final_insert_data,
update_on_duplicate: body.update_on_duplicate,
});
} catch (error: any) {
if (attempt >= 4) {
throw error;
}
}
}
throw new Error(`Couldn't create invoice`);
}
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -1,29 +0,0 @@
import type { AdminCrudAPIParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPost,
gatedCrudPut,
} from "../../(utils)/gated-crud";
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST":
return await gatedCrudPost(params);
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -4,8 +4,8 @@ import type { APIResponseObject } from "@moduletrace/bunext/types";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_ALL_TYPEDEFS,
BUN_SQLITE_COBBLE_MEDIA,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import { rmSync } from "fs";
@@ -30,12 +30,12 @@ export default async function (
}
let query = _.merge<
ServerQueryParam<BUN_SQLITE_BSQLITE_ALL_TYPEDEFS>,
ServerQueryParam<BUN_SQLITE_BSQLITE_ALL_TYPEDEFS>
ServerQueryParam<BUN_SQLITE_COBBLE_ALL_TYPEDEFS>,
ServerQueryParam<BUN_SQLITE_COBBLE_ALL_TYPEDEFS>
>(body?.sql_query || {}, {});
const media_to_delete = await BunSQLite.select<
BUN_SQLITE_BSQLITE_MEDIA,
BUN_SQLITE_COBBLE_MEDIA,
TableType
>({
table: "media",
@@ -68,7 +68,7 @@ export default async function (
});
}
await BunSQLite.delete<BUN_SQLITE_BSQLITE_MEDIA, TableType>({
await BunSQLite.delete<BUN_SQLITE_COBBLE_MEDIA, TableType>({
table: "media",
targetId: media.id,
});
@@ -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 checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import type { AdminCrudAPIParams } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
@@ -10,7 +10,7 @@ export default async function (
): Promise<APIResponseObject> {
const { table, body, user_types, user } = params;
let final_insert_data: BUN_SQLITE_BSQLITE_MEDIA[] = [
let final_insert_data: BUN_SQLITE_COBBLE_MEDIA[] = [
...(body?.insert_data || []),
].map((d) => ({ ...d, user_id: user.id }));
@@ -1,29 +0,0 @@
import type { AdminCrudAPIParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPost,
gatedCrudPut,
} from "../../(utils)/gated-crud";
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST":
return await gatedCrudPost(params);
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -1,107 +0,0 @@
import type { BUN_SQLITE_BSQLITE_QUOTES } from "@/db/types/db";
import { SiteData } from "@/src/data/site-data";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import grabNextDocumentSequence from "@/src/functions/backend/db/documents/grab-next-document-sequence";
import type { AdminCrudAPIParams } from "@/src/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import {
gatedCrudDelete,
gatedCrudGet,
gatedCrudPut,
} from "../../(utils)/gated-crud";
function quoteNumberPrefix(): string {
const now = new Date();
return `${SiteData["SiteSlug"].toUpperCase()}Q-${now.getFullYear()}${String(
now.getMonth() + 1,
).padStart(2, "0")}`;
}
async function buildQuoteRows({
rows,
}: {
rows: BUN_SQLITE_BSQLITE_QUOTES[];
}): Promise<BUN_SQLITE_BSQLITE_QUOTES[]> {
const prefix = quoteNumberPrefix();
let next_sequence: number | undefined;
const final_rows: BUN_SQLITE_BSQLITE_QUOTES[] = [];
for (const row of rows) {
const quote_row = { ...row } as BUN_SQLITE_BSQLITE_QUOTES;
if (!quote_row.quote_number) {
next_sequence ??= await grabNextDocumentSequence({
table: "quotes",
column: "quote_number",
prefix,
});
quote_row.quote_number = `${prefix}-${String(next_sequence).padStart(3, "0")}`;
next_sequence += 1;
}
final_rows.push(quote_row);
}
return final_rows;
}
export default async function (
params: AdminCrudAPIParams,
): Promise<APIResponseObject> {
switch (params.req.method) {
case "GET":
return await gatedCrudGet(params);
case "POST": {
const { table, body, user_types } = params;
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
if (!body?.insert_data) {
return {
success: false,
msg: `No data to insert`,
};
}
for (let attempt = 0; attempt < 5; attempt++) {
const final_insert_data = await buildQuoteRows({
rows: body.insert_data as BUN_SQLITE_BSQLITE_QUOTES[],
});
try {
return await BunSQLite.insert({
table,
data: final_insert_data,
update_on_duplicate: body.update_on_duplicate,
});
} catch (error: any) {
if (attempt >= 4) {
throw error;
}
}
}
throw new Error(`Couldn't create quote`);
}
case "PUT":
return await gatedCrudPut(params);
case "DELETE":
return await gatedCrudDelete(params);
default:
return {
success: false,
msg: `Unhandled`,
};
}
}
@@ -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 {
RolesAdminCantUpdate,
SuperAdminOnlyEditableUserTypes,
@@ -18,7 +18,7 @@ export default async function (
let final_sql_query = _.merge(query?.sql_query, body?.sql_query) || {};
const records_to_delete =
await BunSQLite.select<BUN_SQLITE_BSQLITE_ALL_TYPEDEFS>({
await BunSQLite.select<BUN_SQLITE_COBBLE_ALL_TYPEDEFS>({
table,
query: final_sql_query,
targetId: 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 checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import checkAllowedJoins from "@/src/functions/backend/db/check-allowed-joins";
import grabUsers from "@/src/functions/backend/db/users/grab-users";
@@ -14,8 +14,8 @@ export default async function (
let final_sql_query = _.merge(query?.sql_query, body?.sql_query) || {};
let final_query =
_.merge<
ApiReqParams<BUN_SQLITE_BSQLITE_ALL_TYPEDEFS>,
ApiReqParams<BUN_SQLITE_BSQLITE_ALL_TYPEDEFS>
ApiReqParams<BUN_SQLITE_COBBLE_ALL_TYPEDEFS>,
ApiReqParams<BUN_SQLITE_COBBLE_ALL_TYPEDEFS>
>(query || {}, { sql_query: final_sql_query }) || {};
const allowed_joins: TableType[] = ["user_types", "media"];
-24
View File
@@ -2,14 +2,6 @@ import _ from "lodash";
import users from "./(tables)/users";
import userTypes from "./(tables)/user_types";
import media from "./(tables)/media";
import letters from "./(tables)/letters";
import emails from "./(tables)/emails";
import emailAttachments from "./(tables)/email_attachments";
import invoices from "./(tables)/invoices";
import invoiceItems from "./(tables)/invoice_items";
import quotes from "./(tables)/quotes";
import quoteItems from "./(tables)/quote_items";
import invoiceQuotes from "./(tables)/invoice_quotes";
import get from "./(functions)/get";
import put from "./(functions)/put";
import del from "./(functions)/delete";
@@ -91,22 +83,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
return await userTypes(crud_params);
case "media":
return await media(crud_params);
case "letters":
return await letters(crud_params);
case "emails":
return await emails(crud_params);
case "email_attachments":
return await emailAttachments(crud_params);
case "invoices":
return await invoices(crud_params);
case "invoice_items":
return await invoiceItems(crud_params);
case "quotes":
return await quotes(crud_params);
case "quote_items":
return await quoteItems(crud_params);
case "invoice_quotes":
return await invoiceQuotes(crud_params);
default:
break;
-244
View File
@@ -1,244 +0,0 @@
import TurndownService from "turndown";
import { SiteData } from "@/src/data/site-data";
import grabEmail from "@/src/functions/backend/db/documents/grab-email";
import grabLetter from "@/src/functions/backend/db/documents/grab-letter";
import grabEmailAttachments from "@/src/functions/backend/db/documents/grab-email-attachments";
import grabInvoice, {
grabInvoiceItems,
} from "@/src/functions/backend/db/invoices/grab-invoice";
import grabQuote, {
grabQuoteItems,
} from "@/src/functions/backend/db/quotes/grab-quote";
import sendEmail from "@/src/functions/backend/email/send-email";
import generateLetterPdf from "@/src/functions/backend/pdf/letter-pdf";
import generateInvoicePdf from "@/src/functions/backend/pdf/invoice-pdf";
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import twuiSlugify from "@moduletrace/twui/utils/slugify";
import grabDirNames from "@/src/utils/grab-dir-names";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import BunSQLite from "@moduletrace/bun-sqlite";
import path from "path";
const turndown_service = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
});
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
try {
const body = params.body as ApiReqParams;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
const email_id = body.email_id;
if (!email_id) {
throw new Error(`Email not found`);
}
const email_res = await grabEmail({ id: email_id });
const email = email_res.singleRes;
if (!email) {
throw new Error(`Email not found`);
}
if (email.status == "sent") {
throw new Error(`This email has already been sent`);
}
const to_emails = (email.to_emails || "").trim();
if (!to_emails) {
throw new Error(
`Please provide a recipient email address`,
);
}
let attachments: {
filename: string;
content: Buffer;
contentType: string;
}[] | undefined;
const attachments_res = await grabEmailAttachments({ email_id });
const attachment_rows = attachments_res.payload || [];
if (attachment_rows[0]) {
attachments = [];
for (const attachment of attachment_rows) {
if (attachment.type == "letter" && attachment.letter_id) {
const letter_res = await grabLetter({
id: attachment.letter_id,
});
const letter = letter_res.singleRes;
if (!letter) {
throw new Error(`Attached letter not found`);
}
const pdf = await generateLetterPdf({
template: letter,
});
attachments.push({
filename: `${twuiSlugify(letter.title || "letter")}.pdf`,
content: Buffer.from(pdf),
contentType: "application/pdf",
});
} else if (attachment.type == "invoice" && attachment.invoice_id) {
const invoice_res = await grabInvoice({
id: attachment.invoice_id,
});
const invoice = invoice_res.singleRes;
if (!invoice) {
throw new Error(`Attached invoice not found`);
}
const items_res = await grabInvoiceItems({
invoice_id: attachment.invoice_id,
});
const items = items_res.payload || [];
const pdf = await generateInvoicePdf({
invoice,
items,
});
attachments.push({
filename: `${twuiSlugify(invoice.invoice_number || "invoice")}.pdf`,
content: Buffer.from(pdf),
contentType: "application/pdf",
});
} else if (attachment.type == "quote" && attachment.quote_id) {
const quote_res = await grabQuote({
id: attachment.quote_id,
});
const quote = quote_res.singleRes;
if (!quote) {
throw new Error(`Attached quote not found`);
}
const items_res = await grabQuoteItems({
quote_id: attachment.quote_id,
});
const items = items_res.payload || [];
const pdf = await generateInvoicePdf({
invoice: {
...quote,
invoice_number: quote.quote_number || "",
},
items,
kind: "quote",
});
attachments.push({
filename: `${twuiSlugify(quote.quote_number || "quote")}.pdf`,
content: Buffer.from(pdf),
contentType: "application/pdf",
});
} else if (attachment.media_write_path) {
const { DATA_DIR } = grabDirNames();
const media_file = Bun.file(
path.join(DATA_DIR, attachment.media_write_path),
);
if (!media_file.size) {
throw new Error(
`Attachment file not found: ${attachment.file_name || "untitled"}`,
);
}
attachments.push({
filename:
attachment.file_name ||
`${attachment.id || "attachment"}`,
content: Buffer.from(await media_file.arrayBuffer()),
contentType:
attachment.mime_type ||
attachment.media_mime_type ||
"application/octet-stream",
});
}
}
if (!attachments[0]) {
attachments = undefined;
}
}
const plain_body = turndown_service.turndown(
email.body_html || "",
);
const send_res = await sendEmail({
to: to_emails,
subject:
email.subject ||
email.title ||
`Email from ${SiteData["SiteName"]}`,
text: plain_body,
content: (
<div
dangerouslySetInnerHTML={{
__html: email.body_html || "",
}}
/>
),
title: `${SiteData["SiteName"]}`,
options: attachments ? { attachments } : undefined,
});
if (!send_res.success) {
return send_res;
}
await BunSQLite.update({
table: "emails",
data: {
status: "sent",
sent_at: Date.now(),
},
targetId: email.id,
});
return {
success: true,
msg: `Email sent successfully to ${to_emails}`,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
@@ -1,134 +0,0 @@
import type {
BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS,
} from "@/db/types/db";
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import type { ApiReqParams, TableType } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import BunSQLite from "@moduletrace/bun-sqlite";
export type EmailAttachmentDraft = {
type: "letter" | "invoice" | "quote" | "file";
letter_id?: number;
invoice_id?: number;
quote_id?: number;
media_id?: number;
file_name?: string;
mime_type?: string;
};
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
try {
const body = params.body as ApiReqParams;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
const email_id = body.email_id;
const attachments: EmailAttachmentDraft[] =
(
body as ApiReqParams & {
attachments?: EmailAttachmentDraft[];
}
).attachments || [];
if (!email_id) {
throw new Error(`Email not found`);
}
const existing_res = await BunSQLite.select<BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS>({
table: "email_attachments",
query: {
query: { email_id: { value: email_id } },
},
});
const existing = existing_res.payload || [];
const new_file_media_ids = attachments
.filter((a) => a.type == "file" && a.media_id)
.map((a) => Number(a.media_id));
for (const existing_attachment of existing) {
if (
existing_attachment.type == "file" &&
existing_attachment.media_id &&
!new_file_media_ids.includes(
Number(existing_attachment.media_id),
)
) {
await deleteMedia({
ids: [existing_attachment.media_id],
can_delete_all_media: true,
});
}
}
await BunSQLite.delete({
table: "email_attachments",
query: {
query: { email_id: { value: email_id } },
},
});
if (attachments[0]) {
const insert_data = attachments.map((attachment) => ({
email_id: Number(email_id),
type: attachment.type,
letter_id:
attachment.type == "letter" ? attachment.letter_id : undefined,
invoice_id:
attachment.type == "invoice"
? attachment.invoice_id
: undefined,
quote_id:
attachment.type == "quote" ? attachment.quote_id : undefined,
media_id:
attachment.type == "file" ? attachment.media_id : undefined,
file_name: attachment.file_name,
mime_type: attachment.mime_type,
}));
const insert_res = await BunSQLite.insert<BUN_SQLITE_BSQLITE_EMAIL_ATTACHMENTS, TableType>({
table: "email_attachments",
data: insert_data,
});
if (!insert_res.success) {
throw new Error(insert_res.msg || `Couldn't save attachments`);
}
}
return {
success: true,
msg: `Attachments saved`,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
@@ -1,76 +0,0 @@
import grabInvoice, {
grabInvoiceItems,
} from "@/src/functions/backend/db/invoices/grab-invoice";
import generateInvoicePdf from "@/src/functions/backend/pdf/invoice-pdf";
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import twuiSlugify from "@moduletrace/twui/utils/slugify";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
try {
const body = params.body as ApiReqParams;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
const invoice_id = body?.invoice_id;
if (!invoice_id) {
throw new Error(`Invoice not found`);
}
const invoice_res = await grabInvoice({ id: invoice_id });
const invoice = invoice_res.singleRes;
if (!invoice) {
throw new Error(`Invoice not found`);
}
const items_res = await grabInvoiceItems({
invoice_id,
});
const pdf = await generateInvoicePdf({
invoice,
items: items_res.payload || [],
});
const filename = `${twuiSlugify(invoice.invoice_number || "invoice")}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition":
`attachment; filename="${filename}"`,
},
});
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
-111
View File
@@ -1,111 +0,0 @@
import grabInvoice, {
grabInvoiceItems,
} from "@/src/functions/backend/db/invoices/grab-invoice";
import { SiteData } from "@/src/data/site-data";
import { InvoiceEmailContent } from "@/src/functions/backend/email/document-emails";
import sendEmail from "@/src/functions/backend/email/send-email";
import generateInvoicePdf from "@/src/functions/backend/pdf/invoice-pdf";
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import twuiSlugify from "@moduletrace/twui/utils/slugify";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
try {
const body = params.body as ApiReqParams;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
const invoice_id = body?.invoice_id;
if (!invoice_id) {
throw new Error(`Invoice not found`);
}
const invoice_res = await grabInvoice({ id: invoice_id });
const invoice = invoice_res.singleRes;
if (!invoice) {
throw new Error(`Invoice not found`);
}
const to_email = body?.to_email || invoice.client_email;
if (!to_email) {
throw new Error(
`Please provide a recipient email address`,
);
}
const items_res = await grabInvoiceItems({
invoice_id,
});
const items = items_res.payload || [];
const pdf = await generateInvoicePdf({
invoice,
items,
});
const pdf_filename = `${twuiSlugify(invoice.invoice_number || "invoice")}.pdf`;
const send_res = await sendEmail({
to: to_email,
subject: `Invoice ${invoice.invoice_number || ""} from ${SiteData["SiteName"]}`,
text: `Please find attached invoice ${invoice.invoice_number || ""} for your records.`,
content: (
<InvoiceEmailContent
invoice={invoice}
items={items}
/>
),
title: `${SiteData["SiteName"]}`,
options: {
attachments: [
{
filename: pdf_filename,
content: Buffer.from(pdf),
contentType: "application/pdf",
},
],
},
});
if (!send_res.success) {
return send_res;
}
return {
success: true,
msg: `Invoice sent successfully to ${to_email}`,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
@@ -1,73 +0,0 @@
import grabLetter from "@/src/functions/backend/db/documents/grab-letter";
import generateLetterPdf from "@/src/functions/backend/pdf/letter-pdf";
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import twuiSlugify from "@moduletrace/twui/utils/slugify";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
try {
const body = params.body as ApiReqParams;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
const letter_id = body?.letter_id;
if (!letter_id) {
throw new Error(`Letter not found`);
}
const letter_res = await grabLetter({ id: letter_id });
const letter = letter_res.singleRes;
if (!letter) {
throw new Error(`Letter not found`);
}
const pdf = await generateLetterPdf({
template: letter,
to_name: body?.to_name,
to_address: body?.to_address,
letter_date: body?.letter_date,
});
const filename = `${twuiSlugify(letter.title || "letter")}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition":
`attachment; filename="${filename}"`,
},
});
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
@@ -1,80 +0,0 @@
import grabQuote, {
grabQuoteItems,
} from "@/src/functions/backend/db/quotes/grab-quote";
import generateInvoicePdf from "@/src/functions/backend/pdf/invoice-pdf";
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import twuiSlugify from "@moduletrace/twui/utils/slugify";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
try {
const body = params.body as ApiReqParams;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
const quote_id = body?.quote_id;
if (!quote_id) {
throw new Error(`Quote not found`);
}
const quote_res = await grabQuote({ id: quote_id });
const quote = quote_res.singleRes;
if (!quote) {
throw new Error(`Quote not found`);
}
const items_res = await grabQuoteItems({
quote_id,
});
const pdf = await generateInvoicePdf({
invoice: {
...quote,
invoice_number: quote.quote_number || "",
},
items: items_res.payload || [],
kind: "quote",
});
const filename = `${twuiSlugify(quote.quote_number || "quote")}.pdf`;
return new Response(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition":
`attachment; filename="${filename}"`,
},
});
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};
-115
View File
@@ -1,115 +0,0 @@
import grabQuote, {
grabQuoteItems,
} from "@/src/functions/backend/db/quotes/grab-quote";
import { SiteData } from "@/src/data/site-data";
import { QuoteEmailContent } from "@/src/functions/backend/email/document-emails";
import sendEmail from "@/src/functions/backend/email/send-email";
import generateInvoicePdf from "@/src/functions/backend/pdf/invoice-pdf";
import userAuth from "@/src/functions/backend/auth/user-auth";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import twuiSlugify from "@moduletrace/twui/utils/slugify";
import type { ApiReqParams } from "@/src/types";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
params,
) => {
try {
const body = params.body as ApiReqParams;
const { user, user_types } = await userAuth({ req: params.req });
if (!user?.logged_in_status) {
return {
success: false,
msg: `Unauthorized`,
logoutUser: true,
};
}
const access = checkUserAccess({
user_types,
includes: ["admin"],
});
if (!access.success) {
return access;
}
const quote_id = body?.quote_id;
if (!quote_id) {
throw new Error(`Quote not found`);
}
const quote_res = await grabQuote({ id: quote_id });
const quote = quote_res.singleRes;
if (!quote) {
throw new Error(`Quote not found`);
}
const to_email = body?.to_email || quote.client_email;
if (!to_email) {
throw new Error(
`Please provide a recipient email address`,
);
}
const items_res = await grabQuoteItems({
quote_id,
});
const items = items_res.payload || [];
const pdf = await generateInvoicePdf({
invoice: {
...quote,
invoice_number: quote.quote_number || "",
},
items,
kind: "quote",
});
const pdf_filename = `${twuiSlugify(quote.quote_number || "quote")}.pdf`;
const send_res = await sendEmail({
to: to_email,
subject: `Quotation ${quote.quote_number || ""} from ${SiteData["SiteName"]}`,
text: `Please find attached quotation ${quote.quote_number || ""} for our proposed works.`,
content: (
<QuoteEmailContent
quote={quote}
items={items}
/>
),
title: `${SiteData["SiteName"]}`,
options: {
attachments: [
{
filename: pdf_filename,
content: Buffer.from(pdf),
contentType: "application/pdf",
},
],
},
});
if (!send_res.success) {
return send_res;
}
return {
success: true,
msg: `Quotation sent successfully to ${to_email}`,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
};