Add version files and update imports for trip model; enhance error handling
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
export const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
};
|
||||
|
||||
export function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function fail(message: string, status = 400): Response {
|
||||
return json({ error: message }, status);
|
||||
}
|
||||
|
||||
export function handleOptions(req: Request): Response | null {
|
||||
if (req.method !== "OPTIONS") return null;
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL");
|
||||
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
const supabaseServiceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error("SUPABASE_URL and SUPABASE_ANON_KEY are required");
|
||||
}
|
||||
|
||||
export function createAuthedClient(req: Request) {
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: {
|
||||
headers: { Authorization: authHeader },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createServiceClient() {
|
||||
if (!supabaseServiceRoleKey) {
|
||||
throw new Error("SUPABASE_SERVICE_ROLE_KEY is required");
|
||||
}
|
||||
return createClient(supabaseUrl, supabaseServiceRoleKey);
|
||||
}
|
||||
|
||||
export async function requireUser(req: Request) {
|
||||
const client = createAuthedClient(req);
|
||||
const { data, error } = await client.auth.getUser();
|
||||
if (error || !data.user) {
|
||||
return { client, user: null, error: error?.message ?? "Unauthorized" };
|
||||
}
|
||||
return { client, user: data.user, error: null };
|
||||
}
|
||||
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { handleOptions, json } from "../_shared/http.ts";
|
||||
|
||||
const supabaseUrl = Deno.env.get("SUPABASE_URL");
|
||||
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY");
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error("SUPABASE_URL and SUPABASE_ANON_KEY are required");
|
||||
}
|
||||
|
||||
type JwtClaims = {
|
||||
sub?: string;
|
||||
aud?: string | string[];
|
||||
role?: string;
|
||||
email?: string;
|
||||
exp?: number;
|
||||
iat?: number;
|
||||
nbf?: number;
|
||||
iss?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function parseJwtClaims(token: string): {
|
||||
claims: JwtClaims | null;
|
||||
decodeError: string | null;
|
||||
partsCount: number;
|
||||
} {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return {
|
||||
claims: null,
|
||||
decodeError: `JWT must have 3 parts, got ${parts.length}`,
|
||||
partsCount: parts.length,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLen = (4 - (b64.length % 4)) % 4;
|
||||
const padded = b64 + "=".repeat(padLen);
|
||||
const decoded = atob(padded);
|
||||
const claims = JSON.parse(decoded) as JwtClaims;
|
||||
return { claims, decodeError: null, partsCount: parts.length };
|
||||
} catch (error) {
|
||||
return {
|
||||
claims: null,
|
||||
decodeError: error instanceof Error ? error.message : "Unknown decode error",
|
||||
partsCount: parts.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function unixNow() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "GET" && req.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
const bearerPrefixOk = authHeader?.startsWith("Bearer ") ?? false;
|
||||
const token = bearerPrefixOk ? authHeader!.slice(7).trim() : "";
|
||||
const tokenPresent = token.length > 0;
|
||||
|
||||
const jwtParse = tokenPresent
|
||||
? parseJwtClaims(token)
|
||||
: { claims: null, decodeError: "Missing bearer token", partsCount: 0 };
|
||||
|
||||
const claims = jwtParse.claims;
|
||||
const now = unixNow();
|
||||
|
||||
const authClient = createClient(supabaseUrl, supabaseAnonKey, {
|
||||
global: { headers: { Authorization: authHeader ?? "" } },
|
||||
});
|
||||
const { data: userData, error: userError } = await authClient.auth.getUser();
|
||||
|
||||
const checks = {
|
||||
authorizationHeaderPresent: authHeader != null,
|
||||
bearerPrefixOk,
|
||||
tokenPresent,
|
||||
tokenLength: token.length,
|
||||
tokenPreview: tokenPresent ? `${token.slice(0, 16)}...` : null,
|
||||
jwtPartsCount: jwtParse.partsCount,
|
||||
jwtDecodeError: jwtParse.decodeError,
|
||||
claimSub: claims?.sub ?? null,
|
||||
claimRole: claims?.role ?? null,
|
||||
claimAud: claims?.aud ?? null,
|
||||
claimEmail: claims?.email ?? null,
|
||||
claimIssuer: claims?.iss ?? null,
|
||||
claimExp: claims?.exp ?? null,
|
||||
claimIat: claims?.iat ?? null,
|
||||
claimNbf: claims?.nbf ?? null,
|
||||
nowUnix: now,
|
||||
isExpired:
|
||||
typeof claims?.exp === "number" ? claims.exp <= now : null,
|
||||
notYetValid:
|
||||
typeof claims?.nbf === "number" ? claims.nbf > now : null,
|
||||
authGetUserOk: !!userData.user && !userError,
|
||||
authGetUserError: userError?.message ?? null,
|
||||
authGetUserUserId: userData.user?.id ?? null,
|
||||
authGetUserEmail: userData.user?.email ?? null,
|
||||
};
|
||||
|
||||
return json({
|
||||
function: "auth-debug",
|
||||
verifyJwtDisabled: true,
|
||||
checks,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import {
|
||||
createServiceClient,
|
||||
requireUser,
|
||||
slugify,
|
||||
} from "../_shared/supabase.ts";
|
||||
|
||||
const allowedTypes = new Set(["text", "voice", "operations"]);
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
const serviceClient = createServiceClient();
|
||||
|
||||
let body: {
|
||||
organization_id?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
type?: string;
|
||||
topic?: string;
|
||||
position?: number;
|
||||
is_private?: boolean;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const organizationId = body.organization_id;
|
||||
const name = (body.name ?? "").trim();
|
||||
const description = (body.description ?? "").trim();
|
||||
const type = body.type ?? "text";
|
||||
const topic = body.topic?.trim() || null;
|
||||
const isPrivate = Boolean(body.is_private);
|
||||
const position = Number.isInteger(body.position) ? Number(body.position) : 0;
|
||||
|
||||
if (!organizationId) return fail("organization_id is required");
|
||||
if (!name) return fail("name is required");
|
||||
if (!allowedTypes.has(type)) return fail("type is invalid");
|
||||
|
||||
const { data: member, error: roleError } = await serviceClient
|
||||
.from("organization_members")
|
||||
.select("role")
|
||||
.eq("organization_id", organizationId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (roleError) return fail(roleError.message, 400);
|
||||
if (!member || !["owner", "admin"].includes(member.role)) {
|
||||
return fail("forbidden", 403);
|
||||
}
|
||||
|
||||
const slug = slugify(body.slug?.trim() || name);
|
||||
if (!slug) return fail("slug is invalid");
|
||||
|
||||
const { data: channel, error: createError } = await serviceClient
|
||||
.from("channels")
|
||||
.insert({
|
||||
organization_id: organizationId,
|
||||
name,
|
||||
description,
|
||||
slug,
|
||||
type,
|
||||
topic,
|
||||
is_private: isPrivate,
|
||||
position,
|
||||
created_by: user.id,
|
||||
})
|
||||
.select(
|
||||
"id, organization_id, name, description, slug, type, topic, is_private, position, created_by, created_at",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
if (createError.code === "23505") return fail("channel slug already exists", 409);
|
||||
return fail(createError.message, 400);
|
||||
}
|
||||
|
||||
if (channel.is_private) {
|
||||
const { error: cmError } = await serviceClient.from("channel_members").insert({
|
||||
channel_id: channel.id,
|
||||
user_id: user.id,
|
||||
});
|
||||
if (cmError) return fail(cmError.message, 400);
|
||||
}
|
||||
|
||||
return json({ channel }, 201);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { createServiceClient, requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
const serviceClient = createServiceClient();
|
||||
|
||||
let body: {
|
||||
channel_id?: string;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const channelId = (body.channel_id ?? "").trim();
|
||||
if (!channelId) return fail("channel_id is required");
|
||||
|
||||
const { data: channel, error: channelError } = await serviceClient
|
||||
.from("channels")
|
||||
.select("id, organization_id")
|
||||
.eq("id", channelId)
|
||||
.maybeSingle();
|
||||
if (channelError) return fail(channelError.message, 400);
|
||||
if (!channel) return fail("channel not found", 404);
|
||||
|
||||
const { data: member, error: roleError } = await serviceClient
|
||||
.from("organization_members")
|
||||
.select("role")
|
||||
.eq("organization_id", channel.organization_id)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
if (roleError) return fail(roleError.message, 400);
|
||||
if (!member || !["owner", "admin"].includes(member.role)) {
|
||||
return fail("forbidden", 403);
|
||||
}
|
||||
|
||||
const { error: deleteError } = await serviceClient
|
||||
.from("channels")
|
||||
.delete()
|
||||
.eq("id", channelId);
|
||||
if (deleteError) return fail(deleteError.message, 400);
|
||||
|
||||
return json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { client, user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
let body: { organization_id?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const organizationId = body.organization_id;
|
||||
if (!organizationId) return fail("organization_id is required");
|
||||
|
||||
const { data: membership, error: membershipError } = await client
|
||||
.from("organization_members")
|
||||
.select("organization_id")
|
||||
.eq("organization_id", organizationId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (membershipError) return fail(membershipError.message, 400);
|
||||
if (!membership) return fail("forbidden", 403);
|
||||
|
||||
const { data: channels, error } = await client
|
||||
.from("channels")
|
||||
.select("id, organization_id, name, description, slug, type, topic, is_private, position, created_at")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("position", { ascending: true });
|
||||
|
||||
if (error) return fail(error.message, 400);
|
||||
|
||||
return json({ channels: channels ?? [] });
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { client, user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
let body: { channel_id?: string; before?: string; limit?: number };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const channelId = body.channel_id;
|
||||
if (!channelId) return fail("channel_id is required");
|
||||
|
||||
const limit = Math.max(1, Math.min(Number(body.limit) || 50, 100));
|
||||
const before = body.before?.trim();
|
||||
|
||||
let query = client
|
||||
.from("messages")
|
||||
.select("id, channel_id, author_user_id, content, created_at, edited_at")
|
||||
.eq("channel_id", channelId)
|
||||
.is("deleted_at", null)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (before) {
|
||||
query = query.lt("created_at", before);
|
||||
}
|
||||
|
||||
const { data: rows, error } = await query;
|
||||
if (error) {
|
||||
if (error.code === "42501") return fail("forbidden", 403);
|
||||
return fail(error.message, 400);
|
||||
}
|
||||
|
||||
return json({
|
||||
messages: (rows ?? []).reverse(),
|
||||
next_before: rows && rows.length > 0 ? rows[rows.length - 1].created_at : null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { client, user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
let body: { channel_id?: string; content?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const channelId = body.channel_id;
|
||||
const content = (body.content ?? "").trim();
|
||||
|
||||
if (!channelId) return fail("channel_id is required");
|
||||
if (!content) return fail("content is required");
|
||||
if (content.length > 4000) return fail("content is too long");
|
||||
|
||||
const { data: message, error } = await client
|
||||
.from("messages")
|
||||
.insert({
|
||||
channel_id: channelId,
|
||||
author_user_id: user.id,
|
||||
content,
|
||||
})
|
||||
.select("id, channel_id, author_user_id, content, created_at, edited_at, deleted_at")
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === "42501") return fail("forbidden", 403);
|
||||
return fail(error.message, 400);
|
||||
}
|
||||
|
||||
return json({ message }, 201);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY");
|
||||
const OPENAI_MODEL = Deno.env.get("OPENAI_MODEL") ?? "gpt-5.2";
|
||||
|
||||
type RequestBody = {
|
||||
channel_id?: string;
|
||||
stop_names?: string[];
|
||||
};
|
||||
|
||||
type OpenAiAlias = {
|
||||
original?: string;
|
||||
estimated?: string;
|
||||
};
|
||||
|
||||
const prompt = `You are interpreting abbreviated station names from a rail replacement service display.
|
||||
|
||||
Each entry follows this structure:
|
||||
|
||||
* A 4-letter station code (derived from the real station name, often by removing vowels or compressing syllables)
|
||||
* A 2-letter stop code (ignore this; it is ambiguous and not needed)
|
||||
* An optional "T" indicating terminus (ignore for naming purposes)
|
||||
|
||||
Your task is to infer the full station names from the 4-letter codes.
|
||||
|
||||
Guidelines:
|
||||
|
||||
* Treat the 4-letter code as a compressed version of a real station name (e.g. consonant-heavy, missing vowels, or merged syllables)
|
||||
* Use pattern recognition rather than strict decoding
|
||||
* Prefer real-world plausibility over perfect letter matching
|
||||
* Assume all stations are on the same rail corridor or geographically connected route
|
||||
* Use the sequence of stops to inform your guesses (adjacent stations should make sense geographically)
|
||||
* If a code is slightly irregular, prioritise what fits the route best over what matches the letters exactly
|
||||
|
||||
Output:
|
||||
Return JSON with shape {"aliases":[{"original":"<code>","estimated":"<name>"}]}.
|
||||
Return only valid JSON with no markdown or explanation.`;
|
||||
|
||||
function extractCode(rawStopName: string): string {
|
||||
const firstToken = rawStopName.trim().split(/\s+/)[0] ?? "";
|
||||
const normalized = firstToken.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
return normalized.slice(0, 8);
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
if (!OPENAI_API_KEY) return fail("OPENAI_API_KEY is not configured", 500);
|
||||
|
||||
const { client, user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
let body: RequestBody;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const channelId = (body.channel_id ?? "").trim();
|
||||
const stopNames = Array.isArray(body.stop_names) ? body.stop_names : [];
|
||||
if (!channelId) return fail("channel_id is required");
|
||||
if (stopNames.length == 0) return json({ aliases: [] });
|
||||
if (stopNames.length > 1000) return fail("stop_names is too large");
|
||||
|
||||
const { data: channel, error: channelError } = await client
|
||||
.from("channels")
|
||||
.select("id, type")
|
||||
.eq("id", channelId)
|
||||
.eq("type", "operations")
|
||||
.maybeSingle();
|
||||
if (channelError) return fail(channelError.message, 400);
|
||||
if (!channel) return fail("forbidden", 403);
|
||||
|
||||
const rawByCode = new Map<string, Set<string>>();
|
||||
for (const stopName of stopNames) {
|
||||
const raw = `${stopName ?? ""}`.trim();
|
||||
if (!raw) continue;
|
||||
const code = extractCode(raw);
|
||||
if (!code) continue;
|
||||
const existing = rawByCode.get(code) ?? new Set<string>();
|
||||
existing.add(raw);
|
||||
rawByCode.set(code, existing);
|
||||
}
|
||||
|
||||
const codes = [...rawByCode.keys()];
|
||||
if (codes.length == 0) return json({ aliases: [] });
|
||||
|
||||
const openAiResponse = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: OPENAI_MODEL,
|
||||
temperature: 0,
|
||||
response_format: { type: "json_object" },
|
||||
messages: [
|
||||
{ role: "system", content: prompt },
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
codes,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!openAiResponse.ok) {
|
||||
const details = await openAiResponse.text();
|
||||
return fail(`OpenAI request failed: ${details}`, 502);
|
||||
}
|
||||
|
||||
const completion = await openAiResponse.json();
|
||||
const content = completion?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== "string" || content.trim().length == 0) {
|
||||
return fail("OpenAI returned no content", 502);
|
||||
}
|
||||
|
||||
let parsed: { aliases?: OpenAiAlias[] };
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return fail("OpenAI returned invalid JSON", 502);
|
||||
}
|
||||
|
||||
const codeToEstimated = new Map<string, string>();
|
||||
for (const row of parsed.aliases ?? []) {
|
||||
const original = `${row.original ?? ""}`.trim().toUpperCase();
|
||||
const estimated = `${row.estimated ?? ""}`.trim();
|
||||
if (!original || !estimated) continue;
|
||||
codeToEstimated.set(original, estimated);
|
||||
}
|
||||
|
||||
const aliases = [];
|
||||
for (const [code, rawStops] of rawByCode.entries()) {
|
||||
const estimated = codeToEstimated.get(code);
|
||||
if (!estimated) continue;
|
||||
for (const raw of rawStops) {
|
||||
aliases.push({
|
||||
raw_stop_name: raw,
|
||||
alias_stop_name: estimated,
|
||||
source: "ai",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({ aliases });
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { createServiceClient, requireUser, slugify } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
const serviceClient = createServiceClient();
|
||||
|
||||
let body: { name?: string; slug?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const name = (body.name ?? "").trim();
|
||||
if (!name) return fail("name is required");
|
||||
|
||||
const slug = slugify(body.slug?.trim() || name);
|
||||
if (!slug) return fail("slug is invalid");
|
||||
|
||||
const { data: org, error: createError } = await serviceClient
|
||||
.from("organizations")
|
||||
.insert({
|
||||
name,
|
||||
slug,
|
||||
owner_user_id: user.id,
|
||||
})
|
||||
.select("id, name, slug, icon_url, owner_user_id, created_at")
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
if (createError.code === "23505") return fail("slug is already taken", 409);
|
||||
return fail(createError.message, 400);
|
||||
}
|
||||
|
||||
const { error: membershipError } = await serviceClient
|
||||
.from("organization_members")
|
||||
.insert({
|
||||
organization_id: org.id,
|
||||
user_id: user.id,
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
if (membershipError) return fail(membershipError.message, 400);
|
||||
|
||||
return json({ organization: org }, 201);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { createServiceClient, requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
let body: { token?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const token = (body.token ?? "").trim().toLowerCase();
|
||||
if (!token) return fail("token is required");
|
||||
|
||||
const service = createServiceClient();
|
||||
const { data: invite, error: inviteError } = await service
|
||||
.from("organization_invites")
|
||||
.select(
|
||||
"id, token, organization_id, role, max_uses, uses_count, expires_at, revoked",
|
||||
)
|
||||
.eq("token", token)
|
||||
.maybeSingle();
|
||||
|
||||
if (inviteError) return fail(inviteError.message, 400);
|
||||
if (!invite) return fail("Invite not found", 404);
|
||||
if (invite.revoked) return fail("Invite revoked", 410);
|
||||
|
||||
if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) {
|
||||
return fail("Invite expired", 410);
|
||||
}
|
||||
|
||||
if (invite.uses_count >= invite.max_uses) {
|
||||
return fail("Invite has reached max uses", 410);
|
||||
}
|
||||
|
||||
const { data: existingMember, error: existingMemberError } = await service
|
||||
.from("organization_members")
|
||||
.select("organization_id")
|
||||
.eq("organization_id", invite.organization_id)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingMemberError) return fail(existingMemberError.message, 400);
|
||||
|
||||
if (existingMember) {
|
||||
return json({
|
||||
accepted: true,
|
||||
already_member: true,
|
||||
organization_id: invite.organization_id,
|
||||
});
|
||||
}
|
||||
|
||||
const { error: insertMemberError } = await service
|
||||
.from("organization_members")
|
||||
.insert({
|
||||
organization_id: invite.organization_id,
|
||||
user_id: user.id,
|
||||
role: invite.role ?? "member",
|
||||
});
|
||||
|
||||
if (insertMemberError) return fail(insertMemberError.message, 400);
|
||||
|
||||
const nextUses = invite.uses_count + 1;
|
||||
const shouldRevoke = nextUses >= invite.max_uses;
|
||||
const { error: updateInviteError } = await service
|
||||
.from("organization_invites")
|
||||
.update({
|
||||
uses_count: nextUses,
|
||||
revoked: shouldRevoke,
|
||||
})
|
||||
.eq("id", invite.id);
|
||||
|
||||
if (updateInviteError) return fail(updateInviteError.message, 400);
|
||||
|
||||
return json({
|
||||
accepted: true,
|
||||
already_member: false,
|
||||
organization_id: invite.organization_id,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { createServiceClient, requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
function generateInviteToken(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
let body: { organization_id?: string; max_uses?: number; expires_in_days?: number };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const organizationId = (body.organization_id ?? "").trim();
|
||||
if (!organizationId) return fail("organization_id is required");
|
||||
|
||||
const maxUses = Number.isInteger(body.max_uses) ? Number(body.max_uses) : 1;
|
||||
if (maxUses < 1 || maxUses > 1000) return fail("max_uses must be between 1 and 1000");
|
||||
|
||||
const expiresInDays = Number.isInteger(body.expires_in_days)
|
||||
? Number(body.expires_in_days)
|
||||
: 7;
|
||||
if (expiresInDays < 1 || expiresInDays > 365) {
|
||||
return fail("expires_in_days must be between 1 and 365");
|
||||
}
|
||||
|
||||
const service = createServiceClient();
|
||||
const { data: member, error: memberError } = await service
|
||||
.from("organization_members")
|
||||
.select("role")
|
||||
.eq("organization_id", organizationId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (memberError) return fail(memberError.message, 400);
|
||||
if (!member || !["owner", "admin"].includes(member.role)) return fail("forbidden", 403);
|
||||
|
||||
const expiresAt = new Date(
|
||||
Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
let invite:
|
||||
| {
|
||||
id: string;
|
||||
token: string;
|
||||
organization_id: string;
|
||||
max_uses: number;
|
||||
uses_count: number;
|
||||
expires_at: string | null;
|
||||
revoked: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
| null = null;
|
||||
let lastError: string | null = null;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const token = generateInviteToken();
|
||||
const { data, error } = await service
|
||||
.from("organization_invites")
|
||||
.insert({
|
||||
token,
|
||||
organization_id: organizationId,
|
||||
created_by: user.id,
|
||||
role: "member",
|
||||
max_uses: maxUses,
|
||||
expires_at: expiresAt,
|
||||
})
|
||||
.select("id, token, organization_id, max_uses, uses_count, expires_at, revoked, created_at")
|
||||
.single();
|
||||
|
||||
if (!error) {
|
||||
invite = data;
|
||||
break;
|
||||
}
|
||||
|
||||
lastError = error.message;
|
||||
if (error.code != "23505") break;
|
||||
}
|
||||
|
||||
if (!invite) return fail(lastError ?? "Could not create invite", 400);
|
||||
|
||||
return json({
|
||||
invite,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { requireUser } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "GET" && req.method !== "POST") {
|
||||
return fail("Method not allowed", 405);
|
||||
}
|
||||
|
||||
const { client, user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
const { data, error } = await client
|
||||
.from("organization_members")
|
||||
.select(
|
||||
"role, joined_at, organizations(id, name, slug, icon_url, owner_user_id, created_at)",
|
||||
)
|
||||
.eq("user_id", user.id)
|
||||
.order("joined_at", { ascending: true });
|
||||
|
||||
if (error) return fail(error.message, 400);
|
||||
|
||||
return json({
|
||||
organizations: (data ?? []).map((row) => ({
|
||||
role: row.role,
|
||||
joined_at: row.joined_at,
|
||||
organization: row.organizations,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { fail, handleOptions, json } from "../_shared/http.ts";
|
||||
import { createServiceClient, requireUser, slugify } from "../_shared/supabase.ts";
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const preflight = handleOptions(req);
|
||||
if (preflight) return preflight;
|
||||
|
||||
if (req.method !== "POST") return fail("Method not allowed", 405);
|
||||
|
||||
const { user, error: userError } = await requireUser(req);
|
||||
if (!user) return fail(userError ?? "Unauthorized", 401);
|
||||
|
||||
let body: {
|
||||
organization_id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
icon_url?: string | null;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return fail("Invalid JSON body");
|
||||
}
|
||||
|
||||
const organizationId = (body.organization_id ?? "").trim();
|
||||
if (!organizationId) return fail("organization_id is required");
|
||||
const name = body.name?.trim();
|
||||
const iconUrl = body.icon_url === null ? null : body.icon_url?.trim();
|
||||
if ((name == null || name.length === 0) && body.icon_url === undefined) {
|
||||
return fail("name or icon_url is required");
|
||||
}
|
||||
|
||||
const serviceClient = createServiceClient();
|
||||
const { data: member, error: memberError } = await serviceClient
|
||||
.from("organization_members")
|
||||
.select("role")
|
||||
.eq("organization_id", organizationId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (memberError) return fail(memberError.message, 400);
|
||||
if (!member || !["owner", "admin"].includes(member.role)) {
|
||||
return fail("forbidden", 403);
|
||||
}
|
||||
|
||||
const patch: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
icon_url?: string | null;
|
||||
} = {};
|
||||
|
||||
if (name != null && name.length > 0) {
|
||||
const slug = slugify((body.slug ?? "").trim() || name);
|
||||
if (!slug) return fail("slug is invalid");
|
||||
patch.name = name;
|
||||
patch.slug = slug;
|
||||
}
|
||||
|
||||
if (body.icon_url !== undefined) {
|
||||
patch.icon_url = iconUrl && iconUrl.length > 0 ? iconUrl : null;
|
||||
}
|
||||
|
||||
const { data: organization, error: updateError } = await serviceClient
|
||||
.from("organizations")
|
||||
.update(patch)
|
||||
.eq("id", organizationId)
|
||||
.select("id, name, slug, icon_url, owner_user_id, created_at")
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
if (updateError.code === "23505") return fail("slug is already taken", 409);
|
||||
return fail(updateError.message, 400);
|
||||
}
|
||||
|
||||
return json({ organization });
|
||||
});
|
||||
Reference in New Issue
Block a user