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
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"css.customData": [],
"css.lint.unknownAtRules": "ignore"
"css.lint.unknownAtRules": "ignore",
"typescript.tsdk": "node_modules/typescript/lib"
}
5 changes: 5 additions & 0 deletions app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const runtime = "nodejs";

import { handlers } from "@/auth";

export const { GET, POST } = handlers;
81 changes: 81 additions & 0 deletions app/api/firebase-token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
export const runtime = "nodejs";

import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getAdminAuth } from "@/lib/firebaseAdmin";

/**
* POST /api/firebase-token
*
* UID-Continuity Bridge: Converts a NextAuth session → Firebase custom token.
*
* Flow:
* 1. Verify the caller has a valid NextAuth session.
* 2. Extract email from the session.
* 3. Look up the EXISTING Firebase user by email (getUserByEmail).
* - If found → use that uid. This is CRITICAL: existing Firestore data
* (spots, images, etc.) is scoped to the uid that Firebase originally
* generated for this Google account. If we created a new uid instead,
* all existing data would be orphaned and invisible to the user.
* - If NOT found → this is a genuinely new user who has never signed in.
* Create a new Firebase user (createUser) and use the resulting uid.
* 4. Mint a custom token for that uid and return it.
*
* The client calls signInWithCustomToken(auth, customToken) to establish
* the Firebase session, which populates Firestore security-rules' request.auth.
*/
export async function POST() {
// 1. Verify NextAuth session
const session = await auth();
if (!session?.user?.email) {
return NextResponse.json(
{ error: "Not authenticated" },
{ status: 401 }
);
}

const { email, name, image } = session.user;
const adminAuth = getAdminAuth();

try {
let uid: string;

// 2. Look up existing Firebase user by email FIRST.
//
// WHY THIS MATTERS:
// Firebase Auth originally assigned a uid when this user first signed in
// via signInWithPopup/signInWithRedirect. All their Firestore documents
// (users/{uid}/spots/...) and Storage paths are keyed to that uid.
// If we skip this lookup and always call createUser(), the new user record
// gets a DIFFERENT uid, and every existing spot/image becomes unreachable.
try {
const existingUser = await adminAuth.getUserByEmail(email);
uid = existingUser.uid;
} catch (lookupError: unknown) {
// auth/user-not-found means this is a genuinely new user.
const code = (lookupError as { code?: string })?.code;
if (code === "auth/user-not-found") {
const newUser = await adminAuth.createUser({
email,
displayName: name ?? undefined,
photoURL: image ?? undefined,
});
uid = newUser.uid;
} else {
// Unexpected error — re-throw.
throw lookupError;
}
}

// 3. Mint custom token
const customToken = await adminAuth.createCustomToken(uid);

return NextResponse.json({ customToken });
} catch (error) {
console.error("[firebase-token] Error minting custom token:", error);
return NextResponse.json(
{ error: "Failed to create Firebase token" },
{ status: 500 }
);
}
}
80 changes: 72 additions & 8 deletions app/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export interface FoodSpotLog {
createdAt: number; // epoch ms
/** Base64 JPEG data-URL thumbnail for fast list rendering. */
thumbnail?: string;
/** Firestore document ID (for cloud sync). */
firebaseId?: string;
/** Firebase Auth UID that owns this entry. */
userId?: string;
/** True when the entry has been saved locally but not yet synced to Firestore. */
pendingSync?: boolean;
latitude?: number;
longitude?: number;
}

/** Full-resolution image associated with a spot (one image per spot). */
Expand All @@ -23,6 +31,8 @@ export interface SpotImage {
/** Compressed JPEG binary — stored but NOT indexed. */
blob: Blob;
createdAt: number; // epoch ms
/** Firestore document ID for the image. */
firebaseId?: string;
}

class KeepCheckDB extends Dexie {
Expand Down Expand Up @@ -54,11 +64,6 @@ class KeepCheckDB extends Dexie {
);

// v3 — adds `images` store + optional `thumbnail` field on spots.
// The spots index string is unchanged because `thumbnail` is NOT
// indexed (Dexie only requires indexed fields in the schema string).
// The images store indexes `spotId` for cascade-delete lookups and
// `createdAt` for chronological queries; `blob` is stored but never
// indexed to save space.
this.version(3)
.stores({
spots: "++id, name, category, rating, createdAt",
Expand All @@ -69,14 +74,73 @@ class KeepCheckDB extends Dexie {
.table<FoodSpotLog>("spots")
.toCollection()
.modify((spot) => {
// Backfill — existing rows simply get `undefined` so the
// TypeScript `?` optional field is satisfied. This is a
// no-op for the data but documents the migration intent.
if (spot.thumbnail === undefined) {
spot.thumbnail = undefined;
}
})
);

// v4 — adds `firebaseId` and `userId` fields for cloud sync.
// These are indexed so we can look up spots by their Firestore ID
// and filter by user.
this.version(4)
.stores({
spots: "++id, name, category, rating, createdAt, firebaseId, userId",
images: "++id, spotId, createdAt, firebaseId",
})
.upgrade((tx) =>
tx
.table<FoodSpotLog>("spots")
.toCollection()
.modify((spot) => {
if (spot.firebaseId === undefined) {
spot.firebaseId = undefined;
}
if (spot.userId === undefined) {
spot.userId = undefined;
}
})
);

// v5 — adds `pendingSync` index for offline-first sync tracking.
// When a spot is saved locally but hasn't been synced to Firestore
// yet, pendingSync = true. The sync manager uses this index to
// efficiently sweep all unsynced entries.
this.version(5)
.stores({
spots: "++id, name, category, rating, createdAt, firebaseId, userId, pendingSync",
images: "++id, spotId, createdAt, firebaseId",
})
.upgrade((tx) =>
tx
.table<FoodSpotLog>("spots")
.toCollection()
.modify((spot) => {
if (spot.pendingSync === undefined) {
spot.pendingSync = false;
}
})
);

// v6 — adds `latitude` and `longitude` fields to spots for the map view.
this.version(6)
.stores({
spots: "++id, name, category, rating, createdAt, firebaseId, userId, pendingSync",
images: "++id, spotId, createdAt, firebaseId",
})
.upgrade((tx) =>
tx
.table<FoodSpotLog>("spots")
.toCollection()
.modify((spot) => {
if (spot.latitude === undefined) {
spot.latitude = undefined;
}
if (spot.longitude === undefined) {
spot.longitude = undefined;
}
})
);
}
}

Expand Down
Loading