Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions src/hooks.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,6 @@ describe("hooks.server", () => {
});
});

describe("session expiry", () => {
it("signs out and redirects if session is expired", async () => {
const config = scenarios.activeSubscription("admin");
const expiredAt = Math.floor(Date.now() / 1000) - 3600;
const mock = createSupabaseMock(config);
// Override getSession to return expired session, add signOut stub
mock.auth.getSession = vi.fn().mockResolvedValue({
data: { session: { expires_at: expiredAt, access_token: "x", refresh_token: "y", user: {} } },
error: null,
});
(mock.auth as Record<string, unknown>).signOut = vi.fn().mockResolvedValue({});
mockSupabaseClient.mockReturnValue(mock);
const event = { url: new URL("http://localhost/admin"), cookies: { get: vi.fn(), set: vi.fn(), delete: vi.fn() }, locals: {}, request: new Request("http://localhost/admin") };
await expect(runHandle(event)).rejects.toMatchObject({ location: "/" });
});
});

describe("subscription validation", () => {
it("redirects to /billing if expired", async () => {
await expect(runHandle(createEvent("/admin", scenarios.expiredTrial()))).rejects.toMatchObject({ location: "/billing" });
Expand Down
29 changes: 11 additions & 18 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,27 @@ export const handle: Handle = async ({ event, resolve }) => {

event.locals.supabase = supabase;
const { data: { user } } = await supabase.auth.getUser();
const { data: { session } } = await supabase.auth.getSession();
event.locals.user = user;

// Reject missing or expired sessions
if (session && (!session.expires_at || new Date(session.expires_at * 1000) < new Date())) {
await supabase.auth.signOut();
throw redirect(307, "/");
}

const path = event.url.pathname;
const isPublic = publicRoutes.includes(path) || path.startsWith("/api/");
const isAuthOnly = authOnlyRoutes.includes(path);

// Fetch membership + subscription once, lazily
let _membership: { role: MemberRole | null; tenantId: string | null; facilityId: string | null; active: boolean } | null = null;
// Fetch membership + subscription once via RPC, lazily (same as layout.server.ts)
let _ctx: { role: MemberRole | null; tenantId: string | null; facilityId: string | null; active: boolean } | null = null;
const getMembership = async (): Promise<{ role: MemberRole | null; tenantId: string | null; facilityId: string | null; active: boolean }> => {
if (_membership) return _membership;
if (!user) return (_membership = { role: null, tenantId: null, facilityId: null, active: false });
if (_ctx) return _ctx;
if (!user) return (_ctx = { role: null, tenantId: null, facilityId: null, active: false });

const { data: m } = await supabase.from("memberships").select("role, tenant_id, facility_id").eq("user_id", user.id).order("is_primary", { ascending: false }).limit(1).single();
if (!m) return (_membership = { role: null, tenantId: null, facilityId: null, active: false });
const { data: ctx } = await supabase.rpc("get_user_context", { p_user_id: user.id });
if (!ctx?.membership) return (_ctx = { role: null, tenantId: null, facilityId: null, active: false });

const { data: s } = await supabase.from("subscriptions").select("status, current_period_end, trial_end").eq("tenant_id", m.tenant_id).single();
const now = Date.now();
const active = s && ["trialing", "active"].includes(s.status as SubscriptionStatus) &&
((s.current_period_end && new Date(s.current_period_end).getTime() > now) || (s.trial_end && new Date(s.trial_end).getTime() > now));
const sub = ctx.subscription;
const now = new Date();
const active = sub && ["trialing", "active"].includes(sub.status as SubscriptionStatus) &&
((sub.periodEnd && new Date(sub.periodEnd) > now) || (sub.trialEnd && new Date(sub.trialEnd) > now));

return (_membership = { role: m.role as MemberRole, tenantId: m.tenant_id, facilityId: m.facility_id, active: !!active });
return (_ctx = { role: ctx.membership.role as MemberRole, tenantId: ctx.membership.tenantId, facilityId: ctx.membership.facilityId, active: !!active });
};

if (path === "/" && user) {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/testing/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface SupabaseMockConfig { user?: MockUser | null; memberships?: Mock
export const createSupabaseMock = (config: SupabaseMockConfig = {}): {
auth: { getUser: ReturnType<typeof vi.fn>; getSession: ReturnType<typeof vi.fn> };
from: ReturnType<typeof vi.fn>;
rpc: ReturnType<typeof vi.fn>;
} => {
const { user, memberships = [], subscriptions = [], tenants = [] } = config;
const authUser: User | null = user ? { id: user.id, email: user.email, aud: "authenticated", role: "authenticated", email_confirmed_at: new Date().toISOString(), phone: "", confirmed_at: new Date().toISOString(), last_sign_in_at: new Date().toISOString(), app_metadata: {}, user_metadata: { full_name: "Test" }, identities: [], created_at: new Date().toISOString(), updated_at: new Date().toISOString() } : null;
Expand Down Expand Up @@ -52,9 +53,26 @@ export const createSupabaseMock = (config: SupabaseMockConfig = {}): {
return b;
};

// Build get_user_context RPC response from config
const primaryMembership = memberships[0] ?? null;
const subscription = primaryMembership ? subscriptions.find(s => s.tenantId === primaryMembership.tenantId) ?? null : null;
const tenant = primaryMembership ? tenants.find(t => t.id === primaryMembership.tenantId) ?? null : null;

const userContextData = primaryMembership ? {
membership: { role: primaryMembership.role, tenantId: primaryMembership.tenantId, facilityId: primaryMembership.facilityId },
subscription: subscription ? { status: subscription.status, trialEnd: subscription.trialEnd?.toISOString() ?? null, periodEnd: subscription.currentPeriodEnd?.toISOString() ?? null } : null,
tenant: tenant ? { id: tenant.id, name: tenant.name, settings: {} } : null,
profile: { fullName: authUser?.user_metadata?.full_name ?? "Test" },
activeSession: null,
} : null;

return {
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: authUser }, error: null }), getSession: vi.fn().mockResolvedValue({ data: { session }, error: null }) },
from: vi.fn().mockImplementation((t: string) => createBuilder(t)),
rpc: vi.fn().mockImplementation((fn: string) => {
if (fn === "get_user_context") return Promise.resolve({ data: userContextData, error: null });
return Promise.resolve({ data: null, error: null });
}),
};
};

Expand Down
3 changes: 2 additions & 1 deletion src/lib/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export function formatDate(date: string | Date, s?: FormatSettings | null, inclu

export function formatCurrency(value: number, s?: FormatSettings | null): string {
const currency = s?.currency_code ?? "EUR";
return new Intl.NumberFormat(currency === "USD" ? "en-US" : currency === "GBP" ? "en-GB" : "de-DE", { style: "currency", currency }).format(value);
// Use undefined locale so the runtime picks the best locale for the currency
return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(value);
}

const CURRENCY_SYMBOLS: Record<string, string> = {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/utils/supabase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createClient } from "@supabase/supabase-js";
import { env as publicEnv } from "$env/dynamic/public";
import { browser } from "$app/environment";

const { PUBLIC_SUPABASE_URL: url, PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: anonKey } =
publicEnv as {
Expand All @@ -15,7 +16,7 @@ export const supabase = createClient(url, anonKey);

// Keep server and client in sync: when auth state changes on the client,
// update the HTTP-only cookies on the server so SSR sees the session on reload.
if (typeof window !== "undefined") {
if (browser) {
const postAuthCallback = async (
event: string,
session: { access_token: string; refresh_token: string } | null,
Expand Down
4 changes: 2 additions & 2 deletions src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

const { data, children } = $props();

// Sync server data into client stores on every navigation
$effect(() => {
// Sync server data into client stores before render on every navigation
$effect.pre(() => {
session.setUser(data.user);
if (data.settings) settings.setSettings(data.settings);
});
Expand Down
10 changes: 8 additions & 2 deletions src/routes/(app)/admin/settings/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ export const actions: Actions = {
const { supabase, user } = locals;
if (!user) return fail(401, { error: "Unauthorized" });

const { data: ctx } = await supabase.rpc("get_user_context", { p_user_id: user.id });
const tenantId = ctx?.membership?.tenantId;
const { data: m } = await supabase
.from("memberships")
.select("tenant_id")
.eq("user_id", user.id)
.order("is_primary", { ascending: false })
.limit(1)
.single();
const tenantId = m?.tenant_id;
if (!tenantId) return fail(400, { error: "No tenant" });

const formData = await request.formData();
Expand Down
15 changes: 7 additions & 8 deletions src/routes/(app)/bookings/[type]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script lang="ts">
import BookingPage from "$lib/components/features/booking-page.svelte";
import { Cake, Dribbble, Calendar, MoreHorizontal } from "@lucide/svelte";
import { BOOKING_TYPE, type BookingTypeValue } from "$lib/constants";
import { BOOKING_TYPE } from "$lib/constants";
import type { BookingTypeValue } from "$lib/constants";

const { data } = $props();

Expand All @@ -10,13 +11,11 @@
[BOOKING_TYPE.FOOTBALL]: Dribbble,
event: Calendar,
other: MoreHorizontal,
} as const;
} satisfies Record<string, typeof Cake>;

const validTypes = Object.values(BOOKING_TYPE);
type ValidType = BookingTypeValue;
const isValidType = (t: string): t is ValidType => validTypes.includes(t as ValidType);
// data.type is BookingType (includes event/other), cast to BookingTypeValue for BookingPage
const type = $derived(data.type as BookingTypeValue);
const icon = $derived(icons[data.type] ?? Calendar);
</script>

{#if isValidType(data.type)}
<BookingPage type={data.type} bookings={data.bookings} user={data.user} icon={icons[data.type]} />
{/if}
<BookingPage {type} bookings={data.bookings} user={data.user} {icon} />
15 changes: 4 additions & 11 deletions src/routes/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,10 @@ import { getHomeForRole } from "$lib/config/auth";

export const load: PageServerLoad = async ({ locals }) => {
const { user, supabase } = locals;

if (!user) return {};

const { data: membership } = await supabase
.from("memberships")
.select("role")
.eq("user_id", user.id)
.order("is_primary", { ascending: false })
.limit(1)
.single();

if (!membership) throw redirect(307, "/onboarding");
throw redirect(307, getHomeForRole(membership.role));
// Hooks already redirects authenticated users away from / — this is a safety net
const { data: ctx } = await supabase.rpc("get_user_context", { p_user_id: user.id });
if (!ctx?.membership) throw redirect(307, "/onboarding");
throw redirect(307, getHomeForRole(ctx.membership.role as Parameters<typeof getHomeForRole>[0]));
};
3 changes: 3 additions & 0 deletions src/routes/api/keep-alive/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const GET: RequestHandler = async ({ request }) => {
throw error(500, `Keep-alive upsert failed: ${upsertError.message}`);
}

// Also refresh the best-sellers materialized view daily
await admin.rpc("refresh_mv_best_sellers");

return json(
{
success: true,
Expand Down
16 changes: 12 additions & 4 deletions supabase/migrations/0004_functions_triggers.sql
Original file line number Diff line number Diff line change
Expand Up @@ -222,15 +222,23 @@ BEGIN
END;
$$;

-- Product search function
-- Product search function — uses 'simple' dictionary for language-agnostic matching (works for Greek, English, etc.)
-- Also adds trigram fallback for partial/typo matching
CREATE FUNCTION public.search_products(facility_uuid uuid, search_text text)
RETURNS TABLE(id uuid, name text, price numeric, stock_quantity integer, category_id uuid, rank real)
LANGUAGE sql STABLE SET search_path = public AS $$
SELECT p.id, p.name, p.price, p.stock_quantity, p.category_id,
ts_rank(p.search_vector, plainto_tsquery('english', search_text)) as rank
CASE
WHEN search_text = '' THEN 0
ELSE ts_rank(p.search_vector, plainto_tsquery('simple', search_text))
END as rank
FROM products p
WHERE p.facility_id = facility_uuid
AND (search_text = '' OR p.search_vector @@ plainto_tsquery('english', search_text))
WHERE p.facility_id = facility_uuid
AND (
search_text = ''
OR p.search_vector @@ plainto_tsquery('simple', search_text)
OR p.name ILIKE '%' || search_text || '%'
)
ORDER BY rank DESC, p.name;
$$;

Expand Down
13 changes: 6 additions & 7 deletions supabase/migrations/0005_policies.sql
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ CREATE POLICY audit_log_select ON public.audit_log FOR SELECT USING (
);

-- Analytics Views
-- mv_best_sellers is refreshed after each order insert via refresh_mv_best_sellers trigger
-- mv_best_sellers: snapshot of top-selling products
-- NOTE: Refresh manually or via scheduled job — do NOT use a synchronous trigger
-- (synchronous refresh blocks the order INSERT transaction)
CREATE MATERIALIZED VIEW public.mv_best_sellers AS
SELECT p.facility_id, p.id AS product_id, p.name AS product_name, p.category_id,
COALESCE(SUM(oi.quantity), 0) AS total_sold
Expand Down Expand Up @@ -262,18 +264,15 @@ $$;

GRANT EXECUTE ON FUNCTION public.get_user_context TO authenticated;

-- Refresh mv_best_sellers after orders change (async via trigger)
CREATE FUNCTION public.refresh_mv_best_sellers() RETURNS trigger
-- mv_best_sellers is refreshed by the keep-alive cron job (daily) to avoid blocking order transactions
CREATE FUNCTION public.refresh_mv_best_sellers() RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY public.mv_best_sellers;
RETURN NULL;
END;
$$;

CREATE TRIGGER refresh_best_sellers_on_order
AFTER INSERT OR UPDATE OR DELETE ON public.orders
FOR EACH STATEMENT EXECUTE FUNCTION public.refresh_mv_best_sellers();
GRANT EXECUTE ON FUNCTION public.refresh_mv_best_sellers TO service_role;

-- Booking stats RPC for secretary dashboard
CREATE FUNCTION public.get_booking_stats(p_facility_id uuid)
Expand Down
Loading