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