tmp api
This commit is contained in:
parent
c010e18c7e
commit
d1a00f175c
12 changed files with 2776 additions and 11 deletions
|
|
@ -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
2475
api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
69
api/src/helpers.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
23
api/src/supabase_ca.crt
Normal 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-----
|
||||
130
api/src/tablo.ts
130
api/src/tablo.ts
|
|
@ -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
3
api/src/tasks/hello.cjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module.exports = async (payload, helpers) => {
|
||||
helpers.logger.info("Hello World");
|
||||
};
|
||||
8
api/src/tasks/helloWorld.ts
Normal file
8
api/src/tasks/helloWorld.ts
Normal 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");
|
||||
};
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue