diff --git a/README.md b/README.md index 45d8fe3..a19af8d 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,12 @@ npm install - `supabase/migrations/001_initial_schema.sql` - `supabase/migrations/002_demo_type_and_guided_fields.sql` - `supabase/migrations/003_admin_read_all_products.sql` + - `supabase/migrations/004_storage_product_images.sql` + - `supabase/migrations/005_rls_fixes.sql` + - `supabase/migrations/006_current_week_cycle.sql` 3. Enable **Google OAuth** (optional) under Authentication, Providers, Google. -4. Create a public **Storage bucket** named `product-images`. +4. Under **Authentication → URL Configuration**, set your production and local callback URLs (see Deploy section). +5. Storage is created by migration `004`; no manual bucket setup needed if migrations ran in order. ### 3. Configure environment diff --git a/package-lock.json b/package-lock.json index 8a204be..670caa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -623,9 +623,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -642,9 +639,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -661,9 +655,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -680,9 +671,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -699,9 +687,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -718,9 +703,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -737,9 +719,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -756,9 +735,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -775,9 +751,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -800,9 +773,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -825,9 +795,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -850,9 +817,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -875,9 +839,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -900,9 +861,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -925,9 +883,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -950,9 +905,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1162,9 +1114,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1181,9 +1130,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1200,9 +1146,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1219,9 +1162,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1556,9 +1496,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1576,9 +1513,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1596,9 +1530,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1616,9 +1547,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2169,9 +2097,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2186,9 +2111,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2203,9 +2125,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2220,9 +2139,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2237,9 +2153,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2254,9 +2167,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2271,9 +2181,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2288,9 +2195,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4929,9 +4833,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4953,9 +4854,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4977,9 +4875,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5001,9 +4896,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index aea13cd..a137164 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -3,6 +3,7 @@ import { Suspense, useState } from "react"; import { useSearchParams } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; +import { safeRedirectPath } from "@/lib/safe-redirect"; function LoginForm() { const [email, setEmail] = useState(""); @@ -10,7 +11,7 @@ function LoginForm() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const searchParams = useSearchParams(); - const redirect = searchParams.get("redirect") || "/"; + const redirect = safeRedirectPath(searchParams.get("redirect")); const authError = searchParams.get("error"); const supabase = createClient(); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index bcf5a3c..5e2739d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -83,7 +83,11 @@ export default function AdminPage() { } async function updateStatus(id: string, status: string) { - await supabase.from("products").update({ status }).eq("id", id); + const { error } = await supabase.from("products").update({ status }).eq("id", id); + if (error) { + setMessage(`Failed to update product: ${error.message}`); + return; + } await loadProducts(); } @@ -135,6 +139,7 @@ export default function AdminPage() { } setMessage(`Snapshot completed for week of ${weekOf}. ${topProducts.length} winners recorded.`); + await loadDemoDays(); setSnapshotLoading(false); } diff --git a/src/app/api/cron/demo-day/route.ts b/src/app/api/cron/demo-day/route.ts index 3b6724e..1c4efb2 100644 --- a/src/app/api/cron/demo-day/route.ts +++ b/src/app/api/cron/demo-day/route.ts @@ -12,7 +12,6 @@ export async function GET(request: Request) { process.env.SUPABASE_SERVICE_ROLE_KEY! ); - // Get current week_of (Monday of this week in Helsinki time) const { data: weekData, error: weekError } = await supabase.rpc("current_week"); const weekOf = weekData; @@ -27,33 +26,45 @@ export async function GET(request: Request) { return NextResponse.json({ error: "Could not determine current week" }, { status: 500 }); } - // Get top 3 products for the week - const { data: topProducts } = await supabase + const { data: topProducts, error: productsError } = await supabase .from("product_with_counts") .select("id, vote_count, week_of") .eq("week_of", weekOf) .order("vote_count", { ascending: false }) .limit(3); + if (productsError) { + return NextResponse.json({ error: productsError.message }, { status: 500 }); + } + if (!topProducts || topProducts.length === 0) { return NextResponse.json({ message: "No products this week", weekOf }); } - // Upsert demo day - await supabase.from("demo_days").upsert({ + const { error: demoError } = await supabase.from("demo_days").upsert({ week_of: weekOf, demo_date: new Date().toISOString(), status: "completed", }); - // Insert winners + if (demoError) { + return NextResponse.json({ error: demoError.message }, { status: 500 }); + } + for (let i = 0; i < topProducts.length; i++) { - await supabase.from("demo_day_winners").upsert({ + const { error: winnerError } = await supabase.from("demo_day_winners").upsert({ week_of: weekOf, rank: i + 1, product_id: topProducts[i].id, vote_count: topProducts[i].vote_count, }); + + if (winnerError) { + return NextResponse.json( + { error: `Failed to record winner ${i + 1}: ${winnerError.message}` }, + { status: 500 } + ); + } } return NextResponse.json({ diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 75213cc..7a30785 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from "next/server"; import { createServerClient, parseCookieHeader } from "@supabase/ssr"; import type { CookieOptions } from "@supabase/ssr"; +import { safeRedirectPath } from "@/lib/safe-redirect"; export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); - const redirect = searchParams.get("redirect") || "/"; + const redirect = safeRedirectPath(searchParams.get("redirect")); const errorParam = searchParams.get("error"); const errorDescription = searchParams.get("error_description"); @@ -47,16 +48,9 @@ export async function GET(request: Request) { const { data, error } = await supabase.auth.exchangeCodeForSession(code); - console.log( - "[auth/callback] exchange result:", - error ? `error=${error.message}` : "ok", - `cookies collected: ${collectedCookies.length}`, - `cookie names: [${collectedCookies.map((c) => c.name).join(", ")}]` - ); - if (!error && data.session) { const user = data.session.user; - let redirectTo = `${origin}${redirect}`; + let redirectPath = redirect; if (user) { const { data: profile } = await supabase @@ -66,25 +60,22 @@ export async function GET(request: Request) { .maybeSingle(); if (!profile?.handle) { - redirectTo = `${origin}/onboarding`; + redirectPath = "/onboarding"; } } const forwardedHost = request.headers.get("x-forwarded-host"); - if (forwardedHost && process.env.NODE_ENV !== "development") { - redirectTo = `https://${forwardedHost}${redirect}`; - } + const host = + forwardedHost && process.env.NODE_ENV !== "development" + ? `https://${forwardedHost.split(",")[0].trim()}` + : origin; + const redirectTo = `${host}${redirectPath}`; const response = NextResponse.redirect(redirectTo); for (const { name, value, options } of collectedCookies) { response.cookies.set(name, value, options); } - console.log( - "[auth/callback] redirect to:", - redirectTo, - `Set-Cookie count: ${response.headers.getSetCookie().length}` - ); return response; } diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index c90a483..3374245 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -27,8 +27,7 @@ async function getProducts(): Promise { .order("created_at", { ascending: false }) .limit(20); - const { data: { session } } = await supabase.auth.getSession(); - const user = session?.user ?? null; + const { data: { user } } = await supabase.auth.getUser(); let result = (products ?? []) as ProductWithCounts[]; diff --git a/src/app/p/[id]/actions.ts b/src/app/p/[id]/actions.ts index e11c228..6393b1b 100644 --- a/src/app/p/[id]/actions.ts +++ b/src/app/p/[id]/actions.ts @@ -39,6 +39,10 @@ export async function updateProduct( return { error: "You can only edit your own products." }; } + if (data.demo_type === "live_demo" && !data.demo_week) { + return { error: "Pick a Friday demo slot for live demos." }; + } + const { error } = await supabase .from("products") .update({ diff --git a/src/app/p/[id]/edit/page.tsx b/src/app/p/[id]/edit/page.tsx index 9fb371a..cbf4fb6 100644 --- a/src/app/p/[id]/edit/page.tsx +++ b/src/app/p/[id]/edit/page.tsx @@ -93,6 +93,12 @@ export default function EditProductPage() { setError(null); setSaved(false); + if (demoType === "live_demo" && !demoWeek) { + setError("Pick a Friday demo slot for live demos."); + setLoading(false); + return; + } + const result = await updateProduct(productId, { name, tagline, diff --git a/src/app/p/[id]/page.tsx b/src/app/p/[id]/page.tsx index 271d8db..969cac1 100644 --- a/src/app/p/[id]/page.tsx +++ b/src/app/p/[id]/page.tsx @@ -5,7 +5,8 @@ import { VoteButton } from "@/components/vote-button"; import { StageDot } from "@/components/stage-dot"; import { CommentSection } from "@/components/comment-section"; import { isMockMode, MOCK_PRODUCTS, MOCK_COMMENTS } from "@/lib/mock-data"; -import type { Comment, ProductBuilder, ProductWithCounts } from "@/types/database"; +import { parseBuilder } from "@/lib/parse-builder"; +import type { Comment, ProductWithCounts } from "@/types/database"; import type { Metadata } from "next"; interface Props { @@ -29,15 +30,45 @@ async function getProduct(id: string): Promise { const { createClient } = await import("@/lib/supabase/server"); const supabase = await createClient(); - const { data } = await supabase - .from("product_with_counts") - .select("*") - .eq("id", id) - .single(); - const product = data as ProductWithCounts | null; + const { data: { user } } = await supabase.auth.getUser(); - const { data: { session } } = await supabase.auth.getSession(); - const user = session?.user ?? null; + let product = ( + await supabase.from("product_with_counts").select("*").eq("id", id).maybeSingle() + ).data as ProductWithCounts | null; + + // Owners can view hidden/removed products (RLS allows; view filters live-only) + if (!product && user) { + const { data: ownProduct } = await supabase + .from("products") + .select("*") + .eq("id", id) + .eq("builder_id", user.id) + .maybeSingle(); + + if (ownProduct) { + const [{ count: voteCount }, { count: commentCount }, { data: profile }] = + await Promise.all([ + supabase.from("votes").select("*", { count: "exact", head: true }).eq("product_id", id), + supabase + .from("comments") + .select("*", { count: "exact", head: true }) + .eq("product_id", id) + .eq("status", "live"), + supabase + .from("profiles") + .select("display_name, handle, avatar_url") + .eq("id", ownProduct.builder_id) + .single(), + ]); + + product = { + ...ownProduct, + vote_count: voteCount ?? 0, + comment_count: commentCount ?? 0, + builder: profile ?? { display_name: "Builder", handle: null, avatar_url: null }, + }; + } + } let userHasVoted = false; if (user) { @@ -55,16 +86,6 @@ async function getProduct(id: string): Promise { return { product, userHasVoted, isOwner, comments: [] }; } -function parseBuilder(builder: ProductWithCounts["builder"]): ProductBuilder | null { - if (typeof builder !== "string") return builder; - - try { - return JSON.parse(builder) as ProductBuilder; - } catch { - return null; - } -} - export async function generateMetadata({ params }: Props): Promise { const { id } = await params; if (isMockMode()) { diff --git a/src/app/p/[id]/prep/page.tsx b/src/app/p/[id]/prep/page.tsx index 787e7a9..4ec52ff 100644 --- a/src/app/p/[id]/prep/page.tsx +++ b/src/app/p/[id]/prep/page.tsx @@ -26,8 +26,7 @@ async function getProductForPrep(id: string): Promise<{ .eq("id", id) .single(); - const { data: { session } } = await supabase.auth.getSession(); - const user = session?.user ?? null; + const { data: { user } } = await supabase.auth.getUser(); const isOwner = !!user && product?.builder_id === user.id; diff --git a/src/app/page.tsx b/src/app/page.tsx index 0a36c55..d832235 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -24,8 +24,7 @@ async function getProducts(sortMode: "hot" | "new"): Promise = { - builder_id: user.id, - name: name.trim(), - tagline: tagline.trim(), - description: description.trim() || null, - url: finalUrl, - image_url, - category, - stage, - week_of: currentWeekOf(), - }; - const retry = await supabase - .from("products") - .insert(safePayload) - .select("id") - .single(); - data = retry.data; - insertError = retry.error; - } + const data = result.data; + const insertError = result.error; if (insertError) { - setError(insertError.message); + if (insertError.message.includes("column")) { + setError( + "Database schema is out of date. Run migration 002_demo_type_and_guided_fields.sql in Supabase." + ); + } else { + setError(insertError.message); + } setLoading(false); return; } @@ -189,7 +177,11 @@ export default function SubmitPage() { } } - const canSubmit = name.trim() && tagline.trim() && demoType; + const canSubmit = + name.trim() && + tagline.trim() && + demoType && + (demoType !== "live_demo" || demoWeek); return (
diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index 8915924..cf771bf 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -38,8 +38,7 @@ async function getProfileData(handle: string) { .eq("builder_id", profile.id) .order("created_at", { ascending: false }); - const { data: { session } } = await supabase.auth.getSession(); - const user = session?.user ?? null; + const { data: { user } } = await supabase.auth.getUser(); let productsWithVotes = (products ?? []) as ProductWithCounts[]; if (user && products && products.length > 0) { diff --git a/src/components/product-card.tsx b/src/components/product-card.tsx index 9c0799f..bd8a155 100644 --- a/src/components/product-card.tsx +++ b/src/components/product-card.tsx @@ -3,17 +3,15 @@ import Link from "next/link"; import { VoteButton } from "./vote-button"; import { StageDot } from "./stage-dot"; -import type { ProductBuilder, ProductWithCounts } from "@/types/database"; +import { parseBuilder } from "@/lib/parse-builder"; +import type { ProductWithCounts } from "@/types/database"; interface ProductCardProps { product: ProductWithCounts; } export function ProductCard({ product }: ProductCardProps) { - const builder: ProductBuilder = - typeof product.builder === "string" - ? (JSON.parse(product.builder) as ProductBuilder) - : product.builder; + const builder = parseBuilder(product.builder); return (
diff --git a/src/components/vote-button.tsx b/src/components/vote-button.tsx index bfcef43..2481df2 100644 --- a/src/components/vote-button.tsx +++ b/src/components/vote-button.tsx @@ -19,6 +19,7 @@ export function VoteButton({ }: VoteButtonProps) { const [voted, setVoted] = useState(initialVoted); const [count, setCount] = useState(initialCount); + const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); const mock = isMockMode(); const voteLabel = `${voted ? "Remove vote from" : "Vote for"} ${productName}. ${count} ${ @@ -33,6 +34,7 @@ export function VoteButton({ } startTransition(async () => { + setError(null); const supabase = createClient(); const { data: { user } } = await supabase.auth.getUser(); @@ -42,41 +44,52 @@ export function VoteButton({ } if (voted) { - const { error } = await supabase + const { error: voteError } = await supabase .from("votes") .delete() .eq("user_id", user.id) .eq("product_id", productId); - if (!error) { - setVoted(false); - setCount((c) => c - 1); + if (voteError) { + setError(voteError.message); + return; } + setVoted(false); + setCount((c) => c - 1); } else { - const { error } = await supabase + const { error: voteError } = await supabase .from("votes") .insert({ user_id: user.id, product_id: productId }); - if (!error) { - setVoted(true); - setCount((c) => c + 1); + if (voteError) { + setError(voteError.message); + return; } + setVoted(true); + setCount((c) => c + 1); } }); } return ( - +
+ + {error && ( + + {error} + + )} +
); } diff --git a/src/lib/parse-builder.ts b/src/lib/parse-builder.ts new file mode 100644 index 0000000..9c4b25c --- /dev/null +++ b/src/lib/parse-builder.ts @@ -0,0 +1,14 @@ +import type { ProductBuilder } from "@/types/database"; + +export function parseBuilder( + builder: ProductBuilder | string | null | undefined +): ProductBuilder | null { + if (!builder) return null; + if (typeof builder !== "string") return builder; + + try { + return JSON.parse(builder) as ProductBuilder; + } catch { + return null; + } +} diff --git a/src/lib/safe-redirect.ts b/src/lib/safe-redirect.ts new file mode 100644 index 0000000..593210b --- /dev/null +++ b/src/lib/safe-redirect.ts @@ -0,0 +1,10 @@ +/** Allow only same-origin relative paths (blocks open redirects). */ +export function safeRedirectPath(raw: string | null | undefined): string { + if (!raw || !raw.startsWith("/") || raw.startsWith("//")) { + return "/"; + } + if (raw.includes("://") || raw.includes("\\")) { + return "/"; + } + return raw; +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 9f2891b..fea9b60 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -1,8 +1,7 @@ import { createBrowserClient } from "@supabase/ssr"; +import { getSupabaseEnv } from "./env"; export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ); + const { url, anonKey } = getSupabaseEnv(); + return createBrowserClient(url, anonKey); } diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts new file mode 100644 index 0000000..fc6befd --- /dev/null +++ b/src/lib/supabase/env.ts @@ -0,0 +1,16 @@ +import { isMockMode } from "@/lib/mock-data"; + +export function getSupabaseEnv() { + if (isMockMode()) { + return { + url: "https://placeholder.supabase.co", + anonKey: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBsYWNlaG9sZGVyIn0.placeholder", + }; + } + + return { + url: process.env.NEXT_PUBLIC_SUPABASE_URL!, + anonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + }; +} diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts index b71c76b..fd4f672 100644 --- a/src/lib/supabase/middleware.ts +++ b/src/lib/supabase/middleware.ts @@ -1,5 +1,8 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +import { getSupabaseEnv } from "./env"; + +const PUBLIC_PATHS = ["/login", "/onboarding", "/auth"]; export async function updateSession(request: NextRequest) { const pathname = request.nextUrl.pathname; @@ -9,56 +12,38 @@ export async function updateSession(request: NextRequest) { } let supabaseResponse = NextResponse.next({ request }); - let setAllCalled = false; - let setAllCookieNames: string[] = []; + const { url, anonKey } = getSupabaseEnv(); - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet, headers) { - setAllCalled = true; - setAllCookieNames = cookiesToSet.map( - (c) => `${c.name}=${c.value ? "set" : "CLEAR(maxAge=0)"}` - ); - cookiesToSet.forEach(({ name, value }) => - request.cookies.set(name, value) - ); - supabaseResponse = NextResponse.next({ request }); - cookiesToSet.forEach(({ name, value, options }) => - supabaseResponse.cookies.set(name, value, options) + const supabase = createServerClient(url, anonKey, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet, headers) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ); + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ); + if (headers) { + Object.entries(headers).forEach(([key, value]) => + supabaseResponse.headers.set(key, value as string) ); - if (headers) { - Object.entries(headers).forEach(([key, value]) => - supabaseResponse.headers.set(key, value as string) - ); - } - }, + } }, - } - ); - - const authCookieNames = request.cookies - .getAll() - .filter((c) => c.name.includes("auth-token")) - .map((c) => c.name); + }, + }); const { data: { user }, } = await supabase.auth.getUser(); - console.log( - `[proxy] ${pathname} | cookies: [${authCookieNames.join(", ")}] | user: ${user?.id ?? "none"} | setAll: ${setAllCalled ? `YES [${setAllCookieNames.join(", ")}]` : "no"}` - ); - const protectedRoutes = ["/submit", "/settings", "/admin"]; - const isProtected = protectedRoutes.some((route) => - pathname.startsWith(route) - ) || /^\/p\/[^/]+\/edit/.test(pathname); + const isProtected = + protectedRoutes.some((route) => pathname.startsWith(route)) || + /^\/p\/[^/]+\/edit/.test(pathname); if (isProtected && !user) { const url = request.nextUrl.clone(); @@ -67,5 +52,20 @@ export async function updateSession(request: NextRequest) { return NextResponse.redirect(url); } + const isPublicAuthPath = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); + if (user && !isPublicAuthPath) { + const { data: profile } = await supabase + .from("profiles") + .select("handle") + .eq("id", user.id) + .maybeSingle(); + + if (!profile?.handle) { + const url = request.nextUrl.clone(); + url.pathname = "/onboarding"; + return NextResponse.redirect(url); + } + } + return supabaseResponse; } diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index dac2490..b8d36fc 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -1,27 +1,25 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; +import { getSupabaseEnv } from "./env"; export async function createClient() { const cookieStore = await cookies(); + const { url, anonKey } = getSupabaseEnv(); - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ); - } catch { - // Called from a Server Component — ignore - } - }, + return createServerClient(url, anonKey, { + cookies: { + getAll() { + return cookieStore.getAll(); }, - } - ); + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // Called from a Server Component — ignore + } + }, + }, + }); } diff --git a/src/lib/week.ts b/src/lib/week.ts index a9e722c..efb674a 100644 --- a/src/lib/week.ts +++ b/src/lib/week.ts @@ -18,13 +18,31 @@ export function helsinkiNow(): Date { return new TZDate(new Date(), TZ); } +/** True after Friday demo ends or on Sat/Sun — submissions belong to the next cycle. */ +function rollsToNextCycle(now: Date): boolean { + const day = now.getDay(); + if (day === 0 || day === 6) return true; + if ( + isFriday(now) && + (now.getHours() > DEMO_END_HOUR || + (now.getHours() === DEMO_END_HOUR && now.getMinutes() > DEMO_END_MINUTE)) + ) { + return true; + } + return false; +} + /** * Returns the Monday date key for the active Helsinki submission week. - * Weekend submissions roll into the upcoming week for the next Friday demo. + * Weekend submissions and post-demo Friday roll into the upcoming week. */ export function currentWeekOf(): string { const now = helsinkiNow(); - return format(startOfWeek(now, { weekStartsOn: 1 }), "yyyy-MM-dd"); + let weekStart = startOfWeek(now, { weekStartsOn: 1 }); + if (rollsToNextCycle(now)) { + weekStart = addWeeks(weekStart, 1); + } + return format(weekStart, "yyyy-MM-dd"); } /** @@ -50,9 +68,7 @@ export function upcomingDemoFridays(count = 4): { date: string; label: string }[ let candidate = nextFriday(now); if (isFriday(now)) { - const cutoff = new Date(now); - cutoff.setHours(DEMO_START_HOUR, 0, 0, 0); - if (now < cutoff) candidate = new Date(now); + if (now.getHours() < DEMO_END_HOUR) candidate = new Date(now); } for (let i = 0; i < count; i++) { diff --git a/supabase/migrations/004_storage_product_images.sql b/supabase/migrations/004_storage_product_images.sql new file mode 100644 index 0000000..91e817d --- /dev/null +++ b/supabase/migrations/004_storage_product_images.sql @@ -0,0 +1,25 @@ +-- Product images storage bucket and RLS policies + +INSERT INTO storage.buckets (id, name, public) +VALUES ('product-images', 'product-images', true) +ON CONFLICT (id) DO NOTHING; + +CREATE POLICY "Users upload own images" + ON storage.objects FOR INSERT TO authenticated + WITH CHECK ( + bucket_id = 'product-images' + AND (storage.foldername(name))[1] = 'products' + AND (storage.foldername(name))[2] = auth.uid()::text + ); + +CREATE POLICY "Users update own images" + ON storage.objects FOR UPDATE TO authenticated + USING ( + bucket_id = 'product-images' + AND (storage.foldername(name))[1] = 'products' + AND (storage.foldername(name))[2] = auth.uid()::text + ); + +CREATE POLICY "Public read product images" + ON storage.objects FOR SELECT TO public + USING (bucket_id = 'product-images'); diff --git a/supabase/migrations/005_rls_fixes.sql b/supabase/migrations/005_rls_fixes.sql new file mode 100644 index 0000000..fdb852b --- /dev/null +++ b/supabase/migrations/005_rls_fixes.sql @@ -0,0 +1,40 @@ +-- RLS fixes: builder self-read, live-only votes/comments, demo archive visibility + +CREATE POLICY "Builders can read their own products" + ON public.products FOR SELECT + USING (auth.uid() = builder_id); + +CREATE POLICY "Demo winner products are publicly readable" + ON public.products FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.demo_day_winners w + WHERE w.product_id = products.id + ) + ); + +DROP POLICY IF EXISTS "Authenticated users can vote" ON public.votes; +CREATE POLICY "Authenticated users can vote on live products" + ON public.votes FOR INSERT + WITH CHECK ( + auth.uid() = user_id + AND EXISTS ( + SELECT 1 FROM public.products + WHERE id = product_id AND status = 'live' + ) + ); + +DROP POLICY IF EXISTS "Authenticated users can comment" ON public.comments; +CREATE POLICY "Authenticated users can comment on live products" + ON public.comments FOR INSERT + WITH CHECK ( + auth.uid() = author_id + AND EXISTS ( + SELECT 1 FROM public.products + WHERE id = product_id AND status = 'live' + ) + ); + +ALTER TABLE public.profiles + ADD CONSTRAINT profiles_bio_length + CHECK (bio IS NULL OR char_length(bio) <= 200); diff --git a/supabase/migrations/006_current_week_cycle.sql b/supabase/migrations/006_current_week_cycle.sql new file mode 100644 index 0000000..5d3c74c --- /dev/null +++ b/supabase/migrations/006_current_week_cycle.sql @@ -0,0 +1,25 @@ +-- Align current_week() with the Sat–Fri submission cycle (matches src/lib/week.ts) + +CREATE OR REPLACE FUNCTION public.current_week() RETURNS date AS $$ +DECLARE + helsinki_local timestamp; + dow int; + week_monday date; +BEGIN + helsinki_local := (now() AT TIME ZONE 'Europe/Helsinki'); + dow := EXTRACT(DOW FROM helsinki_local)::int; + week_monday := date_trunc('week', helsinki_local)::date; + + -- After Friday demo ends, or on weekends → next cycle (Saturday 00:00 onward) + IF dow = 0 OR dow = 6 THEN + week_monday := week_monday + 7; + ELSIF dow = 5 AND ( + EXTRACT(HOUR FROM helsinki_local) > 15 + OR (EXTRACT(HOUR FROM helsinki_local) = 15 AND EXTRACT(MINUTE FROM helsinki_local) > 30) + ) THEN + week_monday := week_monday + 7; + END IF; + + RETURN week_monday; +END; +$$ LANGUAGE plpgsql STABLE;