This commit is contained in:
Arthur Belleville 2025-07-29 21:24:12 +02:00
parent c010e18c7e
commit d1a00f175c
No known key found for this signature in database
12 changed files with 2776 additions and 11 deletions

View file

@ -1,6 +1,8 @@
SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0MTMyMSwiZXhwIjoyMDU2ODE3MzIxfQ.9r33CUsu6ZR4vyv4ed-UY6cLE1FZzSSxTNE8pFUKjN4
SUPABASE_CONNECTION_STRING="postgresql://postgres.mhcafqvzbrrwvahpvvzd:mke0dwp@cnv.MFZ@mpa@aws-0-eu-west-3.pooler.supabase.com:6543/postgres"
STREAM_CHAT_API_KEY=t5vvvddteapa
STREAM_CHAT_API_SECRET=zrr32sqenw3atpv9rnz2nhhyyncf7bunr7fmfqy9r7e69fcw978dhzevmhpxa2jj
@ -8,4 +10,8 @@ EMAIL_USER="baptiste@xtablo.com"
EMAIL_KEY="jayf pzpj nrsv vtim"
XTABLO_URL="https://app-staging.xtablo.com"
CORS_ORIGIN="http://localhost:5173"
CORS_ORIGIN="http://localhost:5173"
R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
R2_ACCESS_KEY_ID="caeb987bbcd601708a93c6aa562064ef"
R2_SECRET_ACCESS_KEY="42e455b25804687f7cff3d15be23c1f0f47ca742d7a41b6fa1a05a91041e0215"

2475
api/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,13 +7,18 @@
"start": "node dist/index.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@hono/node-server": "^1.14.4",
"@supabase/supabase-js": "^2.49.4",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"graphile-worker": "^0.16.6",
"hono": "^4.7.7",
"hono-sessions": "^0.7.2",
"multer": "^2.0.2",
"nodemailer": "^7.0.4",
"stream-chat": "^9.8.0"
"stream-chat": "^9.8.0",
"ts-node": "^10.9.2"
},
"devDependencies": {
"@types/node": "^20.11.17",

View file

@ -5,11 +5,15 @@ export interface AppConfig {
PORT: number;
SUPABASE_URL: string;
SUPABASE_SERVICE_ROLE_KEY: string;
SUPABASE_CONNECTION_STRING: string;
STREAM_CHAT_API_KEY: string;
STREAM_CHAT_API_SECRET: string;
EMAIL_USER: string;
EMAIL_KEY: string;
XTABLO_URL: string;
R2_ACCOUNT_ID: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
CORS_ORIGIN: string[];
LOG_LEVEL: "debug" | "info" | "warn" | "error";
}
@ -39,6 +43,10 @@ function createConfig(): AppConfig {
"SUPABASE_SERVICE_ROLE_KEY",
process.env.SUPABASE_SERVICE_ROLE_KEY
),
SUPABASE_CONNECTION_STRING: validateEnvVar(
"SUPABASE_CONNECTION_STRING",
process.env.SUPABASE_CONNECTION_STRING
),
STREAM_CHAT_API_KEY: validateEnvVar(
"STREAM_CHAT_API_KEY",
process.env.STREAM_CHAT_API_KEY
@ -51,6 +59,15 @@ function createConfig(): AppConfig {
EMAIL_KEY: validateEnvVar("EMAIL_KEY", process.env.EMAIL_KEY),
CORS_ORIGIN: [process.env.CORS_ORIGIN || "https://app.xtablo.com"],
XTABLO_URL: process.env.XTABLO_URL || "https://app.xtablo.com",
R2_ACCOUNT_ID: validateEnvVar("R2_ACCOUNT_ID", process.env.R2_ACCOUNT_ID),
R2_ACCESS_KEY_ID: validateEnvVar(
"R2_ACCESS_KEY_ID",
process.env.R2_ACCESS_KEY_ID
),
R2_SECRET_ACCESS_KEY: validateEnvVar(
"R2_SECRET_ACCESS_KEY",
process.env.R2_SECRET_ACCESS_KEY
),
LOG_LEVEL: "info",
};

69
api/src/helpers.ts Normal file
View file

@ -0,0 +1,69 @@
import type { EventAndTablo } from "./types.js";
export const generateICSFromEvents = (
events: EventAndTablo[],
calendarName: string = "Planning"
) => {
const formatDate = (date: string, time: string) => {
// Combine date (YYYY-MM-DD) and time (HH:MM:SS) into ISO format then convert to UTC
const dateTime = new Date(`${date}T${time}`);
return dateTime.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
};
const escapeICSText = (text: string) => {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n")
.replace(/\r/g, "");
};
const generateUID = (eventId: string) => {
return `${eventId}@xtablo.com`;
};
let icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//XTablo//Planning Export//EN",
`X-WR-CALNAME:${escapeICSText(calendarName)}`,
"X-WR-TIMEZONE:Europe/Paris",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
].join("\r\n");
events.forEach((event) => {
if (!event.start_date || !event.start_time || !event.title) return;
const startDateTime = formatDate(event.start_date, event.start_time);
const endDateTime = event.end_time
? formatDate(event.start_date, event.end_time)
: formatDate(event.start_date, event.start_time); // Default to start time if no end time
const eventLines = [
"",
"BEGIN:VEVENT",
`UID:${generateUID(event.event_id)}`,
`DTSTART:${startDateTime}`,
`DTEND:${endDateTime}`,
`SUMMARY:${escapeICSText(event.title)}`,
`DESCRIPTION:${escapeICSText(
`Tablo: ${event.tablo_name}\n${event.description || ""}`
)}`,
event.tablo_name ? `CATEGORIES:${escapeICSText(event.tablo_name)}` : "",
`CREATED:${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`,
`LAST-MODIFIED:${
new Date().toISOString().replace(/[-:]/g, "").split(".")[0]
}Z`,
"STATUS:CONFIRMED",
"TRANSP:OPAQUE",
"END:VEVENT",
].filter((line) => line !== ""); // Remove empty lines
icsContent += "\r\n" + eventLines.join("\r\n");
});
icsContent += "\r\n" + "END:VCALENDAR";
return icsContent;
};

View file

@ -5,6 +5,12 @@ import { mainRouter } from "./routers.js";
import { cors } from "hono/cors";
import { config } from "./config.js";
import { run } from "graphile-worker";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
const __dirname = path.dirname(__filename); // get the name of the directory
const app = new Hono();
@ -30,6 +36,27 @@ app.use("*", async (c, next) => {
app.route("/api/v1", mainRouter);
const worker = async () => {
const connectionString = `${
config.SUPABASE_CONNECTION_STRING
}?ssl=true&sslrootcert=${path.resolve(__dirname, "supabase_ca.crt")}`;
const runner = await run({
connectionString,
concurrency: 1,
pollInterval: 1000,
taskDirectory: path.resolve(__dirname, "tasks"),
noPreparedStatements: true,
});
await runner.promise;
};
worker().catch((err) => {
console.error(err);
process.exit(1);
});
serve(
{
fetch: app.fetch,

View file

@ -1,7 +1,9 @@
import { S3Client } from "@aws-sdk/client-s3";
import { createClient, type User } from "@supabase/supabase-js";
import type { Context, Next } from "hono";
import nodemailer from "nodemailer";
import { StreamChat } from "stream-chat";
import { config } from "./config.js";
// Create authentication middleware
export const authMiddleware = async (c: Context, next: Next) => {
@ -63,3 +65,16 @@ export const streamChatMiddleware = async (c: Context, next: Next) => {
c.set("streamServerClient", serverClient);
await next();
};
export const r2Middleware = async (c: Context, next: Next) => {
const s3 = new S3Client({
region: "auto",
endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
});
c.set("s3_client", s3);
await next();
};

23
api/src/supabase_ca.crt Normal file
View file

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL
BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l
dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh
c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow
azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD
YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug
Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW
QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q
DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2
GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi
cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4
O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt
NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX
uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt
aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU
tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b
VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6
jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx
Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2
CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P
o/bKiIz+Fq8=
-----END CERTIFICATE-----

View file

@ -2,15 +2,26 @@ import { Hono } from "hono";
import {
authMiddleware,
emailMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import {
PostgrestError,
type SupabaseClient,
type User,
} from "@supabase/supabase-js";
import type { Transporter } from "nodemailer";
import { generateToken } from "./token.js";
import { config } from "./config.js";
import type { Tables } from "./database.types.js";
import type { StreamChat } from "stream-chat";
import type { TabloInsert, EventInsertInTablo } from "./types.js";
import type {
TabloInsert,
EventInsertInTablo,
EventAndTablo,
} from "./types.js";
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import { generateICSFromEvents } from "./helpers.js";
export const tabloRouter = new Hono<{
Variables: {
@ -18,12 +29,26 @@ export const tabloRouter = new Hono<{
supabase: SupabaseClient;
transporter: Transporter;
streamServerClient: StreamChat;
s3_client: S3Client;
};
}>();
// const webcalRouter = new Hono<{
// Variables: {
// user: User;
// supabase: SupabaseClient;
// s3_client: S3Client;
// };
// }>();
// webcalRouter.use(r2Middleware);
tabloRouter.use(authMiddleware);
tabloRouter.use(emailMiddleware);
tabloRouter.use(streamChatMiddleware);
tabloRouter.use(r2Middleware);
// tabloRouter.route("/webcal", webcalRouter);
type PostTablo = Omit<TabloInsert, "owner_id"> & {
events?: EventInsertInTablo[];
@ -309,3 +334,104 @@ tabloRouter.post("/leave", async (c) => {
return c.json({ message: "Tablo left successfully" });
});
tabloRouter.post("/webcal/generate-url", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const s3_client = c.get("s3_client");
const { tablo_id } = await c.req.json();
if (tablo_id === null) {
return c.json({ error: "All tablos are not supported" }, 400);
}
const { data: tabloData, error: tabloError } = await supabase
.from("tablos")
.select("name")
.eq("id", tablo_id)
.single();
if (tabloError || !tabloData) {
return c.json({ error: "Tablo not found" }, 404);
}
const tabloName = tabloData.name.replace(/ /g, "_");
const { data: accessData, error: accessError } = await supabase
.from("user_tablos")
.select("id")
.eq("id", tablo_id)
.eq("user_id", user.id)
.single();
if (accessError || !accessData) {
return c.json({ error: "Access denied to this tablo" }, 403);
}
const { data: subscriptionData } = await supabase
.from("calendar_subscriptions")
.select("*")
.eq("tablo_id", tablo_id)
.single();
// if (subscriptionError || !subscriptionData) {
// return c.json({ error: "Subscription already exists" }, 400);
// }
if (subscriptionData) {
const token = subscriptionData.token;
const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
return c.json({
webcal_url: null,
http_url: httpUrl,
});
}
const token = generateToken();
const { error } = await supabase.from("calendar_subscriptions").insert({
tablo_id: tablo_id,
token: token,
});
if (error) {
return c.json({ error: "Failed to generate token" }, 500);
}
const bucketName = "calendars";
const key = `${token}/${tabloName}.ics`;
const { data: events, error: eventsError } = await supabase
.from("events_and_tablos")
.select("*")
.eq("tablo_id", tablo_id);
if (eventsError || !events) {
return c.json({ error: "Failed to generate events" }, 500);
}
const eventsData = events as EventAndTablo[];
const icsContent = generateICSFromEvents(eventsData, tabloName);
await s3_client.send(
new PutObjectCommand({
Bucket: bucketName,
Key: key,
Body: icsContent,
})
);
// Return the webcal URL
// const webcalUrl = `webcal://${
// c.req.header("host") || "localhost:3000"
// }/api/v1/tablos/webcal/${tablo_id}/${token}`;
const httpUrl = `https://calendar.xtablo.com/${token}/${tabloName}.ics`;
return c.json({
webcal_url: null,
http_url: httpUrl,
});
});

3
api/src/tasks/hello.cjs Normal file
View file

@ -0,0 +1,3 @@
module.exports = async (payload, helpers) => {
helpers.logger.info("Hello World");
};

View file

@ -0,0 +1,8 @@
import type { JobHelpers } from "graphile-worker";
export const helloWorld = async (
payload: unknown,
helpers: JobHelpers
): Promise<void> => {
helpers.logger.info("Hello World");
};

View file

@ -8,7 +8,10 @@
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "./dist"
"outDir": "./dist",
"declaration": true,
"emitDeclarationOnly": true,
"allowImportingTsExtensions": true
},
"exclude": ["node_modules"]
}