Add version files and update imports for trip model; enhance error handling

This commit is contained in:
ImBenji
2026-03-27 21:17:56 +00:00
parent e41e14e252
commit 427bcadc77
89 changed files with 9455 additions and 395 deletions
+24
View File
@@ -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 });
}
+43
View File
@@ -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);
}
+114
View File
@@ -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 });
});
+42
View File
@@ -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 ?? [] });
});
+48
View File
@@ -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,
});
});
+43
View File
@@ -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 });
});
+53
View File
@@ -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,
});
});
+32
View File
@@ -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,
})),
});
});
+76
View File
@@ -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 });
});