Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ env:
GOOGLE_LOGO_BUCKET_PRIVATE_KEY: mock-private-key
GOOGLE_LOGO_BUCKET_CLIENT_EMAIL: mock@example.iam.gserviceaccount.com
GOOGLE_LOGO_BUCKET_BUCKET_NAME: mock-bucket
GCS_CREDENTIALS: eyJjbGllbnRfZW1haWwiOiJtb2NrQGV4YW1wbGUuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJwcml2YXRlX2tleSI6Ii0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbmZha2Vcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tIn0=

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[cleanup] The three GOOGLE_LOGO_BUCKET_* vars just above are now removed from packages/env and every app env.ts, so they're dead config here. Safe to delete lines 36–38.

# API (mocks)
API_KEY: ci-api-key-placeholder
SUPER_ADMIN_API_KEY: ci-super-admin-key-placeholder
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/.env.cloud-run.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ F3_MAP_BASE_URL=https://map.f3nation.com
F3_CHANNEL=prod
OAUTH_CLIENT_ID=f3-admin-prod
OAUTH_REDIRECT_URI=https://admin.f3nation.com/api/auth/callback
GCS_BUCKET=f3-public-images
GCS_EMULATOR_HOST=

# Pino log level: trace | debug | info | warn | error | fatal (default: info).
LOG_LEVEL=info
3 changes: 1 addition & 2 deletions apps/admin/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ AUTH_PROVIDER_URL=http://localhost:3004
F3_API_BASE_URL=http://localhost:3001/v1

# Google Cloud Storage (logo uploads — uses local GCS emulator)
GCS_BUCKET=f3-public-images
GCS_CREDENTIALS=local-placeholder
GCS_CREDENTIALS=local-placeholder-not-used-with-emulator
GCS_EMULATOR_HOST=localhost:9023

# App
Expand Down
1 change: 0 additions & 1 deletion apps/admin/scripts/cloud-run-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ ENV_FILE_VARS=(
F3_CHANNEL
OAUTH_CLIENT_ID
OAUTH_REDIRECT_URI
GCS_BUCKET
)


Expand Down
29 changes: 19 additions & 10 deletions apps/admin/src/app/api/upload-logo/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

import { prepareImageForStorage, uploadFile } from "@acme/storage";

import { requireAccessToken } from "~/lib/auth/server";
import { logError } from "~/lib/logging";
import { storage } from "~/lib/storage";

const ALLOWED_TYPES = new Set([
"image/jpeg",
Expand All @@ -14,6 +13,15 @@ const ALLOWED_TYPES = new Set([
]);
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB

function parseOptionalSize(
sizeRaw: FormDataEntryValue | null,
): number | undefined | "invalid" {
if (!sizeRaw) return undefined;
const parsed = Number(sizeRaw);
if (!Number.isFinite(parsed) || parsed <= 0) return "invalid";
return parsed;
}

export async function POST(request: NextRequest) {
await requireAccessToken();

Expand All @@ -26,7 +34,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}

if (!orgIdRaw || isNaN(Number(orgIdRaw))) {
const orgIdNum = Number(orgIdRaw);
if (!orgIdRaw || !Number.isInteger(orgIdNum) || orgIdNum <= 0) {
return NextResponse.json({ error: "Invalid orgId" }, { status: 400 });
}

Expand All @@ -44,16 +53,16 @@ export async function POST(request: NextRequest) {
);
}

const orgId = Number(orgIdRaw);
const dimension = sizeRaw ? Number(sizeRaw) : 640;
const size = parseOptionalSize(sizeRaw);
if (size === "invalid") {
return NextResponse.json({ error: "Invalid size" }, { status: 400 });
}

const orgId = orgIdNum;

try {
const buffer = Buffer.from(await fileEntry.arrayBuffer());
const jpeg = await prepareImageForStorage(buffer, {
width: dimension,
height: dimension,
});
const url = await uploadFile(`org-logos/${orgId}.jpg`, jpeg, "image/jpeg");
const url = await storage.uploadOrgLogo(orgId, buffer, { size });

return NextResponse.json({ url });
} catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions apps/admin/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const env = createEnv({
OAUTH_CLIENT_ID: z.string().min(1),
OAUTH_CLIENT_SECRET: z.string().min(1),
OAUTH_REDIRECT_URI: z.string().url(),
// Base64-encoded service-account JSON for GCS public-image uploads.
GCS_CREDENTIALS: z.string().min(1),
GCS_EMULATOR_HOST: z.string().optional(),
},
client: {},
// With experimental__runtimeEnv (Next >= 13.4.4) only client + shared vars
Expand Down
12 changes: 12 additions & 0 deletions apps/admin/src/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import "server-only";
import { createPublicImageStorage } from "@acme/storage";
import { env } from "~/env";

function deriveStorageChannel(channel: string): "staging" | "prod" {
return channel === "prod" ? "prod" : "staging";
}

export const storage = createPublicImageStorage({
channel: deriveStorageChannel(env.F3_CHANNEL),
credentials: env.GCS_CREDENTIALS,
});
5 changes: 1 addition & 4 deletions apps/map/.env.cloud-run.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ DATABASE_URL=postgresql://user:pass@staging.db.f3nation.com:5432/f3_staging
EMAIL_SERVER=smtp://apikey:SG.xxxx@smtp.sendgrid.net:587
F3_GOOGLE_API_KEY=<your-google-maps-api-key>
F3_MAP_API_KEY=<your-map-api-key>
GOOGLE_LOGO_BUCKET_CLIENT_EMAIL=photos-uploader@f3data.iam.gserviceaccount.com
GOOGLE_LOGO_BUCKET_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
GCS_CREDENTIALS=<base64-encoded service-account JSON>
NOTIFY_WEBHOOK_URLS_COMMA_SEPARATED=
SUPER_ADMIN_API_KEY=<your-staging-super-admin-key>
TEST_DATABASE_URL=postgresql://user:pass@staging.db.f3nation.com:5432/f3_test
Expand All @@ -25,8 +24,6 @@ F3_ADMIN_URL=https://staging.admin.f3nation.com
F3_API_BASE_URL=https://staging.api.f3nation.com
F3_CHANNEL=staging
F3_MAP_BASE_URL=https://staging.map.f3nation.com
GOOGLE_LOGO_BUCKET_BUCKET_NAME=f3-logos
GOOGLE_LOGO_BUCKET_PROJECT_ID=f3data
NEXT_PUBLIC_API_URL=https://staging.api.f3nation.com
NEXT_PUBLIC_CHANNEL=staging
NEXT_PUBLIC_GA_MEASUREMENT_ID=
Expand Down
9 changes: 3 additions & 6 deletions apps/map/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,10 @@ F3_MAP_BASE_URL=http://localhost:3000

# -- Google Cloud Storage (GCS emulator on port 9023) -------------------------
# GCS_EMULATOR_HOST tells the upload route to use fake-gcs-server instead of
# the real GCS API. The three GOOGLE_LOGO_BUCKET_* vars must be non-empty to
# pass validation, but their values are ignored when the emulator is active.
# the real GCS API. GCS_CREDENTIALS must be non-empty to pass validation, but
# its value is ignored when the emulator is active.
GCS_EMULATOR_HOST=localhost:9023
GOOGLE_LOGO_BUCKET_BUCKET_NAME=f3-public-images
GOOGLE_LOGO_BUCKET_CLIENT_EMAIL=local@local.local
GOOGLE_LOGO_BUCKET_PRIVATE_KEY=local-placeholder-not-used-with-emulator
GOOGLE_LOGO_BUCKET_PROJECT_ID=f3-local
GCS_CREDENTIALS=local-placeholder-not-used-with-emulator

# -- Client-side URLs (Next.js public vars) -----------------------------------
# NEXT_PUBLIC_API_URL and NEXT_PUBLIC_MAP_URL are NOT used by the map app at
Expand Down
55 changes: 55 additions & 0 deletions apps/map/__tests__/lib/storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";

describe("map storage bootstrap", () => {
it("uses prod storage channel when F3_CHANNEL is prod", async () => {
vi.resetModules();

const createPublicImageStorage = vi.fn(() => ({
isAllowedPublicImageUrl: () => true,
}));

vi.doMock("@acme/storage", () => ({
createPublicImageStorage,
}));

vi.doMock("~/env", () => ({
env: {
F3_CHANNEL: "prod",
GCS_CREDENTIALS: "cred-prod",
},
}));

await import("~/lib/storage");

expect(createPublicImageStorage).toHaveBeenCalledWith({
channel: "prod",
credentials: "cred-prod",
});
});

it("falls back to staging for non-prod channels", async () => {
vi.resetModules();

const createPublicImageStorage = vi.fn(() => ({
isAllowedPublicImageUrl: () => true,
}));

vi.doMock("@acme/storage", () => ({
createPublicImageStorage,
}));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[tests] "preview" isn't a member of the real enum (local|ci|branch|dev|staging|prod). The mock bypasses validation so it passes, but using a real non-prod value like "dev" keeps the test honest.

vi.doMock("~/env", () => ({
env: {
F3_CHANNEL: "preview",
GCS_CREDENTIALS: "cred-staging",
},
}));

await import("~/lib/storage");

expect(createPublicImageStorage).toHaveBeenCalledWith({
channel: "staging",
credentials: "cred-staging",
});
});
});
75 changes: 75 additions & 0 deletions apps/map/__tests__/utils/image/upload-logo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, vi, afterEach } from "vitest";

import { uploadLogo } from "~/utils/image/upload-logo";

afterEach(() => {
vi.restoreAllMocks();
});
Comment on lines +5 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether stubbed globals are being reverted globally or per-test.
rg -n --type=ts '\bvi\.stubGlobal\s*\('
rg -n --type=ts '\bvi\.unstubAllGlobals\s*\('
rg -n --type=ts 'unstubGlobals\s*:\s*true'

Repository: F3-Nation/f3-nation

Length of output: 1260


🏁 Script executed:

cat -n apps/map/__tests__/utils/image/upload-logo.test.ts

Repository: F3-Nation/f3-nation

Length of output: 2367


🏁 Script executed:

# Find vitest config files
fd -e 'config.ts' -e 'vitest.config.ts' -e 'vitest.config.js'

Repository: F3-Nation/f3-nation

Length of output: 662


🏁 Script executed:

cat -n apps/map/vitest.config.ts

Repository: F3-Nation/f3-nation

Length of output: 1998


🏁 Script executed:

fd -type f -name 'vitest-config' packages/ && find packages -name '*vitest*' -type f | head -20

Repository: F3-Nation/f3-nation

Length of output: 234


🏁 Script executed:

find packages -name '*vitest*' | head -20

Repository: F3-Nation/f3-nation

Length of output: 349


🏁 Script executed:

rg -l '`@acme/vitest-config`' packages/

Repository: F3-Nation/f3-nation

Length of output: 45


🏁 Script executed:

rg '`@acme/vitest-config`' --type ts --type tsx

Repository: F3-Nation/f3-nation

Length of output: 91


🏁 Script executed:

rg '`@acme/vitest-config`' --type ts

Repository: F3-Nation/f3-nation

Length of output: 305


🏁 Script executed:

find . -path ./node_modules -prune -o -name 'package.json' -type f -exec grep -l '`@acme/vitest-config`' {} \;

Repository: F3-Nation/f3-nation

Length of output: 164


🏁 Script executed:

cat -n tooling/vitest/package.json && echo "---" && ls -la tooling/vitest/ && echo "---" && find tooling/vitest -type f -name '*.ts' -o -name 'index.js'

Repository: F3-Nation/f3-nation

Length of output: 1404


🏁 Script executed:

cat -n tooling/vitest/coverage.ts

Repository: F3-Nation/f3-nation

Length of output: 1285


Add vi.unstubAllGlobals() to afterEach to prevent test leakage.

vi.restoreAllMocks() does not unstub globals set via vi.stubGlobal. The suite must explicitly call vi.unstubAllGlobals() in the afterEach cleanup (or enable unstubGlobals: true in vitest config) to prevent stubbed fetch globals from leaking into other tests.

Affected test cases
  • Lines 10-32: "returns url on successful upload"
  • Lines 34-53: "throws API-provided error message on failed upload"
  • Lines 55-74: "throws fallback error when failed upload body is unreadable"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/map/__tests__/utils/image/upload-logo.test.ts` around lines 5 - 7, The
afterEach hook in the test file currently only calls vi.restoreAllMocks(), which
does not clean up stubbed globals such as the fetch global that is likely being
stubbed in the test cases. Add vi.unstubAllGlobals() to the afterEach function
alongside the existing vi.restoreAllMocks() call to ensure all stubbed globals
are properly cleaned up after each test, preventing test leakage and pollution
to subsequent tests.


describe("uploadLogo", () => {
it("returns url on successful upload", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({ url: "https://example.com/logo.jpg" }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
),
),
);

const result = await uploadLogo({
file: new Blob(["abc"], { type: "image/png" }),
orgId: 42,
});

expect(result).toBe("https://example.com/logo.jpg");
});

it("throws API-provided error message on failed upload", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ error: "Upload denied" }), {
status: 400,
headers: { "Content-Type": "application/json" },
}),
),
),
);

await expect(
uploadLogo({
file: new Blob(["abc"], { type: "image/png" }),
orgId: 42,
}),
).rejects.toThrow("Upload denied");
});

it("throws fallback error when failed upload body is unreadable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(
new Response("not json", {
status: 500,
headers: { "Content-Type": "text/plain" },
}),
),
),
);

await expect(
uploadLogo({
file: new Blob(["abc"], { type: "image/png" }),
orgId: 42,
}),
).rejects.toThrow("Failed to upload logo");
});
});
1 change: 1 addition & 0 deletions apps/map/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@acme/logger": "workspace:*",
"@acme/mail": "workspace:*",
"@acme/shared": "workspace:^0.1.0",
"@acme/storage": "workspace:^0.2.0",
"@acme/tailwind-config": "workspace:^0.1.0",
"@acme/ui": "workspace:^0.1.0",
"@acme/validators": "workspace:^0.1.0",
Expand Down
5 changes: 1 addition & 4 deletions apps/map/scripts/cloud-run-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@ declare -A SECRET_MAP=(
[EMAIL_SERVER]="EMAIL_SERVER"
[F3_GOOGLE_API_KEY]="F3_GOOGLE_API_KEY"
[F3_MAP_API_KEY]="F3_MAP_API_KEY"
[GOOGLE_LOGO_BUCKET_CLIENT_EMAIL]="GOOGLE_LOGO_BUCKET_CLIENT_EMAIL"
[GOOGLE_LOGO_BUCKET_PRIVATE_KEY]="GOOGLE_LOGO_BUCKET_PRIVATE_KEY"
[GCS_CREDENTIALS]="GCS_CREDENTIALS"
[NOTIFY_WEBHOOK_URLS_COMMA_SEPARATED]="NOTIFY_WEBHOOK_URLS_COMMA_SEPARATED"
[SUPER_ADMIN_API_KEY]="SUPER_ADMIN_API_KEY"
[TEST_DATABASE_URL]="TEST_DATABASE_URL"
Expand All @@ -58,8 +57,6 @@ ENV_FILE_VARS=(
F3_API_BASE_URL
F3_CHANNEL
F3_MAP_BASE_URL
GOOGLE_LOGO_BUCKET_BUCKET_NAME
GOOGLE_LOGO_BUCKET_PROJECT_ID
NEXT_PUBLIC_API_URL
NEXT_PUBLIC_CHANNEL
NEXT_PUBLIC_GA_MEASUREMENT_ID
Expand Down
24 changes: 3 additions & 21 deletions apps/map/src/app/_components/forms/location-event-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { toast } from "@acme/ui/toast";

import { orpc, useQuery } from "~/orpc/react";
import { useUpdateLocationFormContext } from "~/utils/forms";
import { scaleAndCropImage } from "~/utils/image/scale-and-crop-image";
import { uploadLogo } from "~/utils/image/upload-logo";
import { mapStore } from "~/utils/store/map";
import { DebouncedImage } from "../debounced-image";
Expand All @@ -30,12 +29,9 @@ export const LocationEventForm = ({
isAdminForm?: boolean;
}) => {
const form = useUpdateLocationFormContext();
const formId = form.watch("id");
const formRegionId = form.watch("regionId");
const formLocationId = form.watch("locationId");
const formAoId = form.watch("aoId");
console.log("form eventTypeIds", form.getValues().eventTypeIds);

// Get form values
const { data: regionsResponse } = useQuery(
orpc.map.location.regions.queryOptions(),
Expand Down Expand Up @@ -152,7 +148,6 @@ export const LocationEventForm = ({
control={form.control}
name="eventTypeIds"
render={({ field, fieldState }) => {
console.log("eventTypes", eventTypes, field.value);
return (
<div>
<MultiSelect
Expand Down Expand Up @@ -432,27 +427,14 @@ export const LocationEventForm = ({
toast.error("Please select a region first");
return;
}
console.log("files", e.target.files);
const file = e.target.files?.[0];
if (!file) return;

const blob640 = await scaleAndCropImage(file, 640, 640);
if (!blob640) return;
const url640 = await uploadLogo({
file: blob640,
const url = await uploadLogo({
file,
orgId: formRegionId,
requestId: formId,
});
onChange(url640);
const blob64 = await scaleAndCropImage(file, 64, 64);
if (blob64) {
await uploadLogo({
file: blob64,
orgId: formRegionId,
requestId: formId,
size: 64,
});
}
onChange(url);
}}
disabled={lt(formRegionId, 0)}
className="flex-1"
Expand Down
Loading