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
+1
View File
@@ -0,0 +1 @@
v2.84.2
+1
View File
@@ -0,0 +1 @@
v2.188.1
+1
View File
@@ -0,0 +1 @@
postgresql://postgres.fbgvisimvgeksfxpemuk@aws-1-eu-west-2.pooler.supabase.com:5432/postgres
+1
View File
@@ -0,0 +1 @@
17.6.1.084
+1
View File
@@ -0,0 +1 @@
fbgvisimvgeksfxpemuk
+1
View File
@@ -0,0 +1 @@
v14.4
+1
View File
@@ -0,0 +1 @@
fix-optimized-search-function
+1
View File
@@ -0,0 +1 @@
v1.43.3
+35
View File
@@ -0,0 +1,35 @@
[functions.org-create]
verify_jwt = false
[functions.org-list]
verify_jwt = false
[functions.channel-create]
verify_jwt = false
[functions.channel-list]
verify_jwt = false
[functions.channel-delete]
verify_jwt = false
[functions.message-send]
verify_jwt = false
[functions.message-list]
verify_jwt = false
[functions.auth-debug]
verify_jwt = false
[functions.org-update]
verify_jwt = false
[functions.org-invite-create]
verify_jwt = false
[functions.org-invite-accept]
verify_jwt = false
[functions.operations-stop-alias-enhance]
verify_jwt = false
+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 });
});
@@ -0,0 +1,308 @@
create extension if not exists pgcrypto;
do $$
begin
if not exists (select 1 from pg_type where typname = 'organization_role') then
create type public.organization_role as enum ('owner', 'admin', 'member');
end if;
if not exists (select 1 from pg_type where typname = 'channel_type') then
create type public.channel_type as enum ('text', 'voice', 'announcement');
end if;
end $$;
create table if not exists public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text not null unique,
owner_user_id uuid not null references auth.users(id) on delete restrict,
created_at timestamptz not null default now()
);
create table if not exists public.organization_members (
organization_id uuid not null references public.organizations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role public.organization_role not null default 'member',
joined_at timestamptz not null default now(),
primary key (organization_id, user_id)
);
create table if not exists public.channels (
id uuid primary key default gen_random_uuid(),
organization_id uuid not null references public.organizations(id) on delete cascade,
name text not null,
slug text not null,
type public.channel_type not null default 'text',
position integer not null default 0,
topic text,
is_private boolean not null default false,
created_by uuid not null references auth.users(id) on delete restrict,
created_at timestamptz not null default now(),
unique (organization_id, slug)
);
create table if not exists public.channel_members (
channel_id uuid not null references public.channels(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
joined_at timestamptz not null default now(),
primary key (channel_id, user_id)
);
create table if not exists public.messages (
id uuid primary key default gen_random_uuid(),
channel_id uuid not null references public.channels(id) on delete cascade,
author_user_id uuid not null references auth.users(id) on delete restrict,
content text not null check (char_length(content) <= 4000),
created_at timestamptz not null default now(),
edited_at timestamptz,
deleted_at timestamptz
);
create index if not exists idx_org_members_user
on public.organization_members(user_id);
create index if not exists idx_channels_org_position
on public.channels(organization_id, position);
create index if not exists idx_messages_channel_created_at_desc
on public.messages(channel_id, created_at desc);
create or replace function public.is_org_member(org_id uuid, uid uuid default auth.uid())
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.organization_members om
where om.organization_id = org_id
and om.user_id = uid
);
$$;
create or replace function public.org_role(org_id uuid, uid uuid default auth.uid())
returns public.organization_role
language sql
stable
security definer
set search_path = public
as $$
select om.role
from public.organization_members om
where om.organization_id = org_id
and om.user_id = uid
limit 1;
$$;
create or replace function public.can_access_channel(ch_id uuid, uid uuid default auth.uid())
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.channels c
where c.id = ch_id
and public.is_org_member(c.organization_id, uid)
and (
c.is_private = false
or exists (
select 1
from public.channel_members cm
where cm.channel_id = c.id
and cm.user_id = uid
)
)
);
$$;
alter table public.organizations enable row level security;
alter table public.organization_members enable row level security;
alter table public.channels enable row level security;
alter table public.channel_members enable row level security;
alter table public.messages enable row level security;
drop policy if exists "organizations_select_members" on public.organizations;
create policy "organizations_select_members"
on public.organizations
for select
to authenticated
using (public.is_org_member(id));
drop policy if exists "organizations_insert_owner" on public.organizations;
create policy "organizations_insert_owner"
on public.organizations
for insert
to authenticated
with check (owner_user_id = auth.uid());
drop policy if exists "organizations_update_admins" on public.organizations;
create policy "organizations_update_admins"
on public.organizations
for update
to authenticated
using (public.org_role(id) in ('owner', 'admin'))
with check (public.org_role(id) in ('owner', 'admin'));
drop policy if exists "organization_members_select_members" on public.organization_members;
create policy "organization_members_select_members"
on public.organization_members
for select
to authenticated
using (public.is_org_member(organization_id));
drop policy if exists "organization_members_insert_admins" on public.organization_members;
create policy "organization_members_insert_admins"
on public.organization_members
for insert
to authenticated
with check (public.org_role(organization_id) in ('owner', 'admin'));
drop policy if exists "organization_members_update_admins" on public.organization_members;
create policy "organization_members_update_admins"
on public.organization_members
for update
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'))
with check (public.org_role(organization_id) in ('owner', 'admin'));
drop policy if exists "organization_members_delete_admins" on public.organization_members;
create policy "organization_members_delete_admins"
on public.organization_members
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));
drop policy if exists "channels_select_visible" on public.channels;
create policy "channels_select_visible"
on public.channels
for select
to authenticated
using (public.can_access_channel(id));
drop policy if exists "channels_insert_admins" on public.channels;
create policy "channels_insert_admins"
on public.channels
for insert
to authenticated
with check (
public.org_role(organization_id) in ('owner', 'admin')
and created_by = auth.uid()
);
drop policy if exists "channels_update_admins" on public.channels;
create policy "channels_update_admins"
on public.channels
for update
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'))
with check (public.org_role(organization_id) in ('owner', 'admin'));
drop policy if exists "channels_delete_admins" on public.channels;
create policy "channels_delete_admins"
on public.channels
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));
drop policy if exists "channel_members_select_visible" on public.channel_members;
create policy "channel_members_select_visible"
on public.channel_members
for select
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and public.is_org_member(c.organization_id)
)
);
drop policy if exists "channel_members_insert_admins" on public.channel_members;
create policy "channel_members_insert_admins"
on public.channel_members
for insert
to authenticated
with check (
exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "channel_members_delete_admins" on public.channel_members;
create policy "channel_members_delete_admins"
on public.channel_members
for delete
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "messages_select_visible_channel" on public.messages;
create policy "messages_select_visible_channel"
on public.messages
for select
to authenticated
using (public.can_access_channel(channel_id));
drop policy if exists "messages_insert_visible_channel" on public.messages;
create policy "messages_insert_visible_channel"
on public.messages
for insert
to authenticated
with check (
public.can_access_channel(channel_id)
and author_user_id = auth.uid()
and deleted_at is null
);
drop policy if exists "messages_update_author_or_admin" on public.messages;
create policy "messages_update_author_or_admin"
on public.messages
for update
to authenticated
using (
author_user_id = auth.uid()
or exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
)
with check (
author_user_id = auth.uid()
or exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "messages_delete_author_or_admin" on public.messages;
create policy "messages_delete_author_or_admin"
on public.messages
for delete
to authenticated
using (
author_user_id = auth.uid()
or exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
@@ -0,0 +1,31 @@
-- Fix collaboration bootstrap RLS flow:
-- 1) Allow authenticated users to create organizations they own.
-- 2) Allow org owner to insert their initial owner membership row.
drop policy if exists "organizations_insert_owner" on public.organizations;
create policy "organizations_insert_owner"
on public.organizations
for insert
to authenticated
with check (
owner_user_id = auth.uid()
and owner_user_id is not null
);
drop policy if exists "organization_members_insert_admins" on public.organization_members;
create policy "organization_members_insert_admins"
on public.organization_members
for insert
to authenticated
with check (
user_id = auth.uid()
and (
public.org_role(organization_id) in ('owner', 'admin')
or exists (
select 1
from public.organizations o
where o.id = organization_id
and o.owner_user_id = auth.uid()
)
)
);
@@ -0,0 +1,30 @@
-- Switch channel types to text|voice|operations.
-- Existing "announcement" rows are mapped to "operations".
do $$
begin
if exists (select 1 from pg_type where typname = 'channel_type') then
alter table public.channels
alter column type drop default;
alter table public.channels
alter column type type text
using type::text;
drop type public.channel_type;
create type public.channel_type as enum ('text', 'voice', 'operations');
alter table public.channels
alter column type type public.channel_type
using (
case
when type = 'announcement' then 'operations'
when type in ('text', 'voice', 'operations') then type
else 'text'
end
)::public.channel_type;
alter table public.channels
alter column type set default 'text'::public.channel_type;
end if;
end $$;
@@ -0,0 +1,322 @@
-- Fresh reset migration: replace collaboration IDs with lowercase hash-like text IDs.
-- No compatibility shims by design.
create extension if not exists pgcrypto;
-- Drop existing collaboration objects.
drop table if exists public.messages cascade;
drop table if exists public.channel_members cascade;
drop table if exists public.channels cascade;
drop table if exists public.organization_members cascade;
drop table if exists public.organizations cascade;
drop function if exists public.can_access_channel(uuid, uuid);
drop function if exists public.org_role(uuid, uuid);
drop function if exists public.is_org_member(uuid, uuid);
drop function if exists public.can_access_channel(text, uuid);
drop function if exists public.org_role(text, uuid);
drop function if exists public.is_org_member(text, uuid);
-- Keep enum types if already present.
do $$
begin
if not exists (select 1 from pg_type where typname = 'organization_role') then
create type public.organization_role as enum ('owner', 'admin', 'member');
end if;
if not exists (select 1 from pg_type where typname = 'channel_type') then
create type public.channel_type as enum ('text', 'voice', 'announcement');
end if;
end $$;
create or replace function public.gen_hash_id()
returns text
language sql
volatile
as $$
select substring(md5(random()::text || clock_timestamp()::text) from 1 for 16);
$$;
create table public.organizations (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
name text not null,
slug text not null unique,
owner_user_id uuid not null references auth.users(id) on delete restrict,
created_at timestamptz not null default now()
);
create table public.organization_members (
organization_id text not null references public.organizations(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
role public.organization_role not null default 'member',
joined_at timestamptz not null default now(),
primary key (organization_id, user_id)
);
create table public.channels (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
organization_id text not null references public.organizations(id) on delete cascade,
name text not null,
slug text not null,
type public.channel_type not null default 'text',
position integer not null default 0,
topic text,
is_private boolean not null default false,
created_by uuid not null references auth.users(id) on delete restrict,
created_at timestamptz not null default now(),
unique (organization_id, slug)
);
create table public.channel_members (
channel_id text not null references public.channels(id) on delete cascade,
user_id uuid not null references auth.users(id) on delete cascade,
joined_at timestamptz not null default now(),
primary key (channel_id, user_id)
);
create table public.messages (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
channel_id text not null references public.channels(id) on delete cascade,
author_user_id uuid not null references auth.users(id) on delete restrict,
content text not null check (char_length(content) <= 4000),
created_at timestamptz not null default now(),
edited_at timestamptz,
deleted_at timestamptz
);
create index idx_org_members_user on public.organization_members(user_id);
create index idx_channels_org_position on public.channels(organization_id, position);
create index idx_messages_channel_created_at_desc on public.messages(channel_id, created_at desc);
create or replace function public.is_org_member(org_id text, uid uuid default auth.uid())
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.organization_members om
where om.organization_id = org_id
and om.user_id = uid
);
$$;
create or replace function public.org_role(org_id text, uid uuid default auth.uid())
returns public.organization_role
language sql
stable
security definer
set search_path = public
as $$
select om.role
from public.organization_members om
where om.organization_id = org_id
and om.user_id = uid
limit 1;
$$;
create or replace function public.can_access_channel(ch_id text, uid uuid default auth.uid())
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.channels c
where c.id = ch_id
and public.is_org_member(c.organization_id, uid)
and (
c.is_private = false
or exists (
select 1
from public.channel_members cm
where cm.channel_id = c.id
and cm.user_id = uid
)
)
);
$$;
alter table public.organizations enable row level security;
alter table public.organization_members enable row level security;
alter table public.channels enable row level security;
alter table public.channel_members enable row level security;
alter table public.messages enable row level security;
create policy "organizations_select_members"
on public.organizations
for select
to authenticated
using (public.is_org_member(id));
create policy "organizations_insert_owner"
on public.organizations
for insert
to authenticated
with check (owner_user_id = auth.uid());
create policy "organizations_update_admins"
on public.organizations
for update
to authenticated
using (public.org_role(id) in ('owner', 'admin'))
with check (public.org_role(id) in ('owner', 'admin'));
create policy "organization_members_select_members"
on public.organization_members
for select
to authenticated
using (public.is_org_member(organization_id));
create policy "organization_members_insert_admins"
on public.organization_members
for insert
to authenticated
with check (
user_id = auth.uid()
and (
public.org_role(organization_id) in ('owner', 'admin')
or exists (
select 1
from public.organizations o
where o.id = organization_id
and o.owner_user_id = auth.uid()
)
)
);
create policy "organization_members_update_admins"
on public.organization_members
for update
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'))
with check (public.org_role(organization_id) in ('owner', 'admin'));
create policy "organization_members_delete_admins"
on public.organization_members
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));
create policy "channels_select_visible"
on public.channels
for select
to authenticated
using (public.can_access_channel(id));
create policy "channels_insert_admins"
on public.channels
for insert
to authenticated
with check (
public.org_role(organization_id) in ('owner', 'admin')
and created_by = auth.uid()
);
create policy "channels_update_admins"
on public.channels
for update
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'))
with check (public.org_role(organization_id) in ('owner', 'admin'));
create policy "channels_delete_admins"
on public.channels
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));
create policy "channel_members_select_visible"
on public.channel_members
for select
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and public.is_org_member(c.organization_id)
)
);
create policy "channel_members_insert_admins"
on public.channel_members
for insert
to authenticated
with check (
exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
create policy "channel_members_delete_admins"
on public.channel_members
for delete
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
create policy "messages_select_visible_channel"
on public.messages
for select
to authenticated
using (public.can_access_channel(channel_id));
create policy "messages_insert_visible_channel"
on public.messages
for insert
to authenticated
with check (
public.can_access_channel(channel_id)
and author_user_id = auth.uid()
and deleted_at is null
);
create policy "messages_update_author_or_admin"
on public.messages
for update
to authenticated
using (
author_user_id = auth.uid()
or exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
)
with check (
author_user_id = auth.uid()
or exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
create policy "messages_delete_author_or_admin"
on public.messages
for delete
to authenticated
using (
author_user_id = auth.uid()
or exists (
select 1
from public.channels c
where c.id = channel_id
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
@@ -0,0 +1,39 @@
create table if not exists public.organization_invites (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
token text not null unique check (token ~ '^[0-9a-f]{24}$'),
organization_id text not null references public.organizations(id) on delete cascade,
created_by uuid not null references auth.users(id) on delete restrict,
role public.organization_role not null default 'member',
max_uses integer not null default 1 check (max_uses > 0),
uses_count integer not null default 0 check (uses_count >= 0),
expires_at timestamptz,
revoked boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists idx_org_invites_org on public.organization_invites(organization_id);
create index if not exists idx_org_invites_token on public.organization_invites(token);
alter table public.organization_invites enable row level security;
drop policy if exists "organization_invites_select_admins" on public.organization_invites;
create policy "organization_invites_select_admins"
on public.organization_invites
for select
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));
drop policy if exists "organization_invites_insert_admins" on public.organization_invites;
create policy "organization_invites_insert_admins"
on public.organization_invites
for insert
to authenticated
with check (public.org_role(organization_id) in ('owner', 'admin'));
drop policy if exists "organization_invites_update_admins" on public.organization_invites;
create policy "organization_invites_update_admins"
on public.organization_invites
for update
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'))
with check (public.org_role(organization_id) in ('owner', 'admin'));
@@ -0,0 +1,42 @@
-- Ensure collaboration tables are included in Supabase Realtime publication.
do $$
begin
begin
alter publication supabase_realtime add table public.organizations;
exception
when duplicate_object then null;
when undefined_object then null;
end;
begin
alter publication supabase_realtime add table public.organization_members;
exception
when duplicate_object then null;
when undefined_object then null;
end;
begin
alter publication supabase_realtime add table public.channels;
exception
when duplicate_object then null;
when undefined_object then null;
end;
begin
alter publication supabase_realtime add table public.channel_members;
exception
when duplicate_object then null;
when undefined_object then null;
end;
begin
alter publication supabase_realtime add table public.messages;
exception
when duplicate_object then null;
when undefined_object then null;
end;
end $$;
-- Helpful for update/delete realtime payload completeness.
alter table public.messages replica identity full;
@@ -0,0 +1,341 @@
-- Operations channel data model (no service-days table):
-- schedules -> trips -> trip_stops -> stop_updates
create table if not exists public.operations_schedules (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
channel_id text not null references public.channels(id) on delete cascade,
version integer not null check (version > 0),
source_file_name text not null,
source_mime text,
storage_path text,
file_sha256 text,
parser text not null default 'unknown',
parse_status text not null default 'pending',
parse_error text,
uploaded_by uuid not null references auth.users(id) on delete restrict,
uploaded_at timestamptz not null default now(),
parsed_at timestamptz,
is_active boolean not null default false,
unique (channel_id, version)
);
create unique index if not exists idx_operations_schedules_active_per_channel
on public.operations_schedules(channel_id)
where is_active = true;
create index if not exists idx_operations_schedules_channel_uploaded
on public.operations_schedules(channel_id, uploaded_at desc);
create table if not exists public.operations_trips (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
schedule_id text not null references public.operations_schedules(id) on delete cascade,
trip_number text not null,
duty_number text,
running_number text,
direction text,
service_code text,
sort_order integer not null default 0,
created_at timestamptz not null default now()
);
create index if not exists idx_operations_trips_schedule_sort
on public.operations_trips(schedule_id, sort_order, trip_number);
create table if not exists public.operations_trip_stops (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
trip_id text not null references public.operations_trips(id) on delete cascade,
stop_sequence integer not null check (stop_sequence > 0),
stop_name text not null,
stop_code text,
scheduled_time text,
is_timing_point boolean not null default false,
raw_label text,
created_at timestamptz not null default now(),
unique (trip_id, stop_sequence)
);
create index if not exists idx_operations_trip_stops_trip_sequence
on public.operations_trip_stops(trip_id, stop_sequence);
create index if not exists idx_operations_trip_stops_trip_time
on public.operations_trip_stops(trip_id, scheduled_time);
create table if not exists public.operations_stop_updates (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
trip_stop_id text not null references public.operations_trip_stops(id) on delete cascade,
status text,
actual_time text,
vehicle_id text,
notes text,
updated_by uuid not null references auth.users(id) on delete restrict,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (trip_stop_id)
);
create index if not exists idx_operations_stop_updates_trip_stop
on public.operations_stop_updates(trip_stop_id);
alter table public.operations_schedules enable row level security;
alter table public.operations_trips enable row level security;
alter table public.operations_trip_stops enable row level security;
alter table public.operations_stop_updates enable row level security;
drop policy if exists "operations_schedules_select_visible" on public.operations_schedules;
create policy "operations_schedules_select_visible"
on public.operations_schedules
for select
to authenticated
using (public.can_access_channel(channel_id));
drop policy if exists "operations_schedules_insert_admins" on public.operations_schedules;
create policy "operations_schedules_insert_admins"
on public.operations_schedules
for insert
to authenticated
with check (
uploaded_by = auth.uid()
and exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "operations_schedules_update_admins" on public.operations_schedules;
create policy "operations_schedules_update_admins"
on public.operations_schedules
for update
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
)
with check (
exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "operations_schedules_delete_admins" on public.operations_schedules;
create policy "operations_schedules_delete_admins"
on public.operations_schedules
for delete
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "operations_trips_select_visible" on public.operations_trips;
create policy "operations_trips_select_visible"
on public.operations_trips
for select
to authenticated
using (
exists (
select 1
from public.operations_schedules s
where s.id = schedule_id
and public.can_access_channel(s.channel_id)
)
);
drop policy if exists "operations_trips_write_admins" on public.operations_trips;
create policy "operations_trips_write_admins"
on public.operations_trips
for all
to authenticated
using (
exists (
select 1
from public.operations_schedules s
join public.channels c on c.id = s.channel_id
where s.id = schedule_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
)
with check (
exists (
select 1
from public.operations_schedules s
join public.channels c on c.id = s.channel_id
where s.id = schedule_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "operations_trip_stops_select_visible" on public.operations_trip_stops;
create policy "operations_trip_stops_select_visible"
on public.operations_trip_stops
for select
to authenticated
using (
exists (
select 1
from public.operations_trips t
join public.operations_schedules s on s.id = t.schedule_id
where t.id = trip_id
and public.can_access_channel(s.channel_id)
)
);
drop policy if exists "operations_trip_stops_write_admins" on public.operations_trip_stops;
create policy "operations_trip_stops_write_admins"
on public.operations_trip_stops
for all
to authenticated
using (
exists (
select 1
from public.operations_trips t
join public.operations_schedules s on s.id = t.schedule_id
join public.channels c on c.id = s.channel_id
where t.id = trip_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
)
with check (
exists (
select 1
from public.operations_trips t
join public.operations_schedules s on s.id = t.schedule_id
join public.channels c on c.id = s.channel_id
where t.id = trip_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "operations_stop_updates_select_visible" on public.operations_stop_updates;
create policy "operations_stop_updates_select_visible"
on public.operations_stop_updates
for select
to authenticated
using (
exists (
select 1
from public.operations_trip_stops ts
join public.operations_trips t on t.id = ts.trip_id
join public.operations_schedules s on s.id = t.schedule_id
where ts.id = trip_stop_id
and public.can_access_channel(s.channel_id)
)
);
drop policy if exists "operations_stop_updates_insert_members" on public.operations_stop_updates;
create policy "operations_stop_updates_insert_members"
on public.operations_stop_updates
for insert
to authenticated
with check (
updated_by = auth.uid()
and exists (
select 1
from public.operations_trip_stops ts
join public.operations_trips t on t.id = ts.trip_id
join public.operations_schedules s on s.id = t.schedule_id
where ts.id = trip_stop_id
and public.can_access_channel(s.channel_id)
)
);
drop policy if exists "operations_stop_updates_update_members" on public.operations_stop_updates;
create policy "operations_stop_updates_update_members"
on public.operations_stop_updates
for update
to authenticated
using (
exists (
select 1
from public.operations_trip_stops ts
join public.operations_trips t on t.id = ts.trip_id
join public.operations_schedules s on s.id = t.schedule_id
where ts.id = trip_stop_id
and public.can_access_channel(s.channel_id)
)
)
with check (
updated_by = auth.uid()
and exists (
select 1
from public.operations_trip_stops ts
join public.operations_trips t on t.id = ts.trip_id
join public.operations_schedules s on s.id = t.schedule_id
where ts.id = trip_stop_id
and public.can_access_channel(s.channel_id)
)
);
drop policy if exists "operations_stop_updates_delete_members" on public.operations_stop_updates;
create policy "operations_stop_updates_delete_members"
on public.operations_stop_updates
for delete
to authenticated
using (
exists (
select 1
from public.operations_trip_stops ts
join public.operations_trips t on t.id = ts.trip_id
join public.operations_schedules s on s.id = t.schedule_id
where ts.id = trip_stop_id
and public.can_access_channel(s.channel_id)
)
);
-- Keep updated_at fresh on edits.
create or replace function public.tg_set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at := now();
return new;
end;
$$;
drop trigger if exists trg_operations_stop_updates_updated_at on public.operations_stop_updates;
create trigger trg_operations_stop_updates_updated_at
before update on public.operations_stop_updates
for each row execute procedure public.tg_set_updated_at();
-- Realtime availability for operations collaboration features.
do $$
begin
begin
alter publication supabase_realtime add table public.operations_schedules;
exception when duplicate_object then null;
end;
begin
alter publication supabase_realtime add table public.operations_trips;
exception when duplicate_object then null;
end;
begin
alter publication supabase_realtime add table public.operations_trip_stops;
exception when duplicate_object then null;
end;
begin
alter publication supabase_realtime add table public.operations_stop_updates;
exception when duplicate_object then null;
end;
end $$;
@@ -0,0 +1,59 @@
alter table public.organizations
add column if not exists icon_url text;
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'organization-icons',
'organization-icons',
true,
5242880,
array['image/png', 'image/jpeg', 'image/webp', 'image/gif']
)
on conflict (id) do update
set
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
drop policy if exists "org_icons_public_read" on storage.objects;
create policy "org_icons_public_read"
on storage.objects
for select
to public
using (bucket_id = 'organization-icons');
drop policy if exists "org_icons_insert_admins" on storage.objects;
create policy "org_icons_insert_admins"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'organization-icons'
and owner_id = auth.uid()::text
and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin')
);
drop policy if exists "org_icons_update_admins" on storage.objects;
create policy "org_icons_update_admins"
on storage.objects
for update
to authenticated
using (
bucket_id = 'organization-icons'
and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin')
)
with check (
bucket_id = 'organization-icons'
and owner_id = auth.uid()::text
and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin')
);
drop policy if exists "org_icons_delete_admins" on storage.objects;
create policy "org_icons_delete_admins"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'organization-icons'
and public.org_role(split_part(name, '/', 1)) in ('owner', 'admin')
);
@@ -0,0 +1,2 @@
alter table public.operations_trips
rename column running_number to bus_work_number;
@@ -0,0 +1,7 @@
alter table public.channels
add column if not exists description text not null default '';
update public.channels
set description = topic
where coalesce(description, '') = ''
and coalesce(topic, '') <> '';
@@ -0,0 +1,84 @@
create table if not exists public.operations_stop_aliases (
id text primary key default public.gen_hash_id() check (id ~ '^[0-9a-f]{16}$'),
channel_id text not null references public.channels(id) on delete cascade,
raw_stop_name text not null,
raw_stop_name_normalized text generated always as (lower(btrim(raw_stop_name))) stored,
alias_stop_name text not null,
created_by uuid not null references auth.users(id) on delete restrict,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint operations_stop_aliases_alias_not_blank check (char_length(btrim(alias_stop_name)) > 0),
unique (channel_id, raw_stop_name_normalized)
);
create index if not exists idx_operations_stop_aliases_channel
on public.operations_stop_aliases(channel_id);
alter table public.operations_stop_aliases enable row level security;
drop policy if exists "operations_stop_aliases_select_visible" on public.operations_stop_aliases;
create policy "operations_stop_aliases_select_visible"
on public.operations_stop_aliases
for select
to authenticated
using (public.can_access_channel(channel_id));
drop policy if exists "operations_stop_aliases_insert_admins" on public.operations_stop_aliases;
create policy "operations_stop_aliases_insert_admins"
on public.operations_stop_aliases
for insert
to authenticated
with check (
created_by = auth.uid()
and exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "operations_stop_aliases_update_admins" on public.operations_stop_aliases;
create policy "operations_stop_aliases_update_admins"
on public.operations_stop_aliases
for update
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
)
with check (
exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop policy if exists "operations_stop_aliases_delete_admins" on public.operations_stop_aliases;
create policy "operations_stop_aliases_delete_admins"
on public.operations_stop_aliases
for delete
to authenticated
using (
exists (
select 1
from public.channels c
where c.id = channel_id
and c.type = 'operations'
and public.org_role(c.organization_id) in ('owner', 'admin')
)
);
drop trigger if exists trg_operations_stop_aliases_updated_at on public.operations_stop_aliases;
create trigger trg_operations_stop_aliases_updated_at
before update on public.operations_stop_aliases
for each row execute procedure public.tg_set_updated_at();
@@ -0,0 +1,13 @@
alter table public.operations_stop_aliases
add column if not exists source text not null default 'user';
update public.operations_stop_aliases
set source = 'user'
where source is null or btrim(source) = '';
alter table public.operations_stop_aliases
drop constraint if exists operations_stop_aliases_source_check;
alter table public.operations_stop_aliases
add constraint operations_stop_aliases_source_check
check (source in ('user', 'ai'));