Migrate from .NET MAUI to Expo + Convex

Complete rewrite of Scry using TypeScript stack:

- Expo/React Native with Expo Router (file-based routing)
- Convex backend (serverless functions + real-time database)
- Adaptive camera system (expo-camera in Expo Go, Vision Camera in production)
- React Native Skia + fast-opencv for image processing
- GDPR-compliant auth setup with Zitadel OIDC (pending configuration)

Key features:
- Card recognition pipeline ported to TypeScript
- Perceptual hashing (192-bit color pHash)
- CLAHE preprocessing for lighting normalization
- Local SQLite cache with Convex sync
- Collection management with offline support

Removes all .NET/MAUI code (src/, test/, tools/).

💘 Generated with Crush

Assisted-by: Claude Opus 4.5 via Crush <crush@charm.land>
This commit is contained in:
Chris Kruining 2026-02-09 16:16:34 +01:00
parent 56499d5af9
commit 83ab4df537
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
138 changed files with 19136 additions and 7681 deletions

59
convex/_generated/api.d.ts vendored Normal file
View file

@ -0,0 +1,59 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type * as auth from "../auth.js";
import type * as cards from "../cards.js";
import type * as collections from "../collections.js";
import type * as http from "../http.js";
import type * as scanHistory from "../scanHistory.js";
import type * as users from "../users.js";
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
declare const fullApi: ApiFromModules<{
auth: typeof auth;
cards: typeof cards;
collections: typeof collections;
http: typeof http;
scanHistory: typeof scanHistory;
users: typeof users;
}>;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;
export declare const components: {};

23
convex/_generated/api.js Normal file
View file

@ -0,0 +1,23 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi, componentsGeneric } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

60
convex/_generated/dataModel.d.ts vendored Normal file
View file

@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

143
convex/_generated/server.d.ts vendored Normal file
View file

@ -0,0 +1,143 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View file

@ -0,0 +1,93 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define an HTTP action.
*
* The wrapped function will be used to respond to HTTP requests received
* by a Convex deployment if the requests matches the path and method where
* this action is routed. Be sure to route your httpAction in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export const httpAction = httpActionGeneric;

48
convex/auth.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* Convex Auth configuration with Zitadel OIDC.
*
* GDPR Compliance: No user profile data (name, email, image) is stored.
* User details must be fetched from Zitadel userinfo endpoint when needed.
*/
import Zitadel from "@auth/core/providers/zitadel";
import { convexAuth } from "@convex-dev/auth/server";
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Zitadel({
issuer: process.env.AUTH_ZITADEL_ISSUER,
clientId: process.env.AUTH_ZITADEL_ID,
clientSecret: process.env.AUTH_ZITADEL_SECRET,
// Strip all profile data - return only the subject ID
profile(zitadelProfile) {
return {
id: zitadelProfile.sub,
// Intentionally omit: name, email, image
// These must be fetched from Zitadel userinfo endpoint
};
},
}),
],
callbacks: {
// Validate redirect URIs for React Native
async redirect({ redirectTo }) {
const allowedPrefixes = [
"scry://", // App custom scheme
"app://", // Default Expo scheme
"exp://", // Expo Go
"http://localhost",
"https://localhost",
];
const isAllowed = allowedPrefixes.some((prefix) =>
redirectTo.startsWith(prefix)
);
if (!isAllowed) {
throw new Error(`Invalid redirectTo URI: ${redirectTo}`);
}
return redirectTo;
},
},
});

146
convex/cards.ts Normal file
View file

@ -0,0 +1,146 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
// Get all cards (for local caching)
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("cards").collect();
},
});
// Get cards updated after a timestamp (for incremental sync)
export const listUpdatedAfter = query({
args: { since: v.number() },
handler: async (ctx, { since }) => {
return await ctx.db
.query("cards")
.withIndex("by_updated", (q) => q.gt("updatedAt", since))
.collect();
},
});
// Get a single card by Scryfall ID
export const getByScryfallId = query({
args: { scryfallId: v.string() },
handler: async (ctx, { scryfallId }) => {
return await ctx.db
.query("cards")
.withIndex("by_scryfall", (q) => q.eq("scryfallId", scryfallId))
.first();
},
});
// Get cards by oracle ID (all printings of a card)
export const getByOracleId = query({
args: { oracleId: v.string() },
handler: async (ctx, { oracleId }) => {
return await ctx.db
.query("cards")
.withIndex("by_oracle", (q) => q.eq("oracleId", oracleId))
.collect();
},
});
// Search cards by name
export const searchByName = query({
args: { name: v.string() },
handler: async (ctx, { name }) => {
const cards = await ctx.db.query("cards").collect();
const lowerName = name.toLowerCase();
return cards.filter((card) =>
card.name.toLowerCase().includes(lowerName)
);
},
});
// Get total card count
export const count = query({
args: {},
handler: async (ctx) => {
const cards = await ctx.db.query("cards").collect();
return cards.length;
},
});
// Insert a card (used by migration script)
export const insert = mutation({
args: {
scryfallId: v.string(),
oracleId: v.string(),
name: v.string(),
setCode: v.string(),
collectorNumber: v.string(),
rarity: v.string(),
artist: v.optional(v.string()),
imageUri: v.optional(v.string()),
hash: v.bytes(),
hashVersion: v.number(),
},
handler: async (ctx, args) => {
// Check if card already exists
const existing = await ctx.db
.query("cards")
.withIndex("by_scryfall", (q) => q.eq("scryfallId", args.scryfallId))
.first();
if (existing) {
// Update existing card
await ctx.db.patch(existing._id, {
...args,
updatedAt: Date.now(),
});
return existing._id;
}
// Insert new card
return await ctx.db.insert("cards", {
...args,
updatedAt: Date.now(),
});
},
});
// Batch insert cards
export const insertBatch = mutation({
args: {
cards: v.array(
v.object({
scryfallId: v.string(),
oracleId: v.string(),
name: v.string(),
setCode: v.string(),
collectorNumber: v.string(),
rarity: v.string(),
artist: v.optional(v.string()),
imageUri: v.optional(v.string()),
hash: v.bytes(),
hashVersion: v.number(),
})
),
},
handler: async (ctx, { cards }) => {
const results = [];
for (const card of cards) {
const existing = await ctx.db
.query("cards")
.withIndex("by_scryfall", (q) => q.eq("scryfallId", card.scryfallId))
.first();
if (existing) {
await ctx.db.patch(existing._id, {
...card,
updatedAt: Date.now(),
});
results.push(existing._id);
} else {
const id = await ctx.db.insert("cards", {
...card,
updatedAt: Date.now(),
});
results.push(id);
}
}
return results;
},
});

166
convex/collections.ts Normal file
View file

@ -0,0 +1,166 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
// Get user's collection (alias for backwards compat)
export const getByUser = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const entries = await ctx.db
.query("collections")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
// Enrich with card data
const enriched = await Promise.all(
entries.map(async (entry) => {
const card = await ctx.db.get(entry.cardId);
return {
...entry,
card,
};
})
);
return enriched.filter((e) => e.card !== null);
},
});
// Get user's collection
export const getMyCollection = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const entries = await ctx.db
.query("collections")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
// Enrich with card data
const enriched = await Promise.all(
entries.map(async (entry) => {
const card = await ctx.db.get(entry.cardId);
return {
...entry,
card,
};
})
);
return enriched.filter((e) => e.card !== null);
},
});
// Get collection stats
export const getStats = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const entries = await ctx.db
.query("collections")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
const totalCards = entries.reduce((sum, e) => sum + e.quantity, 0);
const uniqueCards = entries.length;
const foilCount = entries.filter((e) => e.isFoil).length;
return {
totalCards,
uniqueCards,
foilCount,
};
},
});
// Add card to collection
export const addCard = mutation({
args: {
userId: v.id("users"),
cardId: v.id("cards"),
quantity: v.number(),
isFoil: v.boolean(),
},
handler: async (ctx, { userId, cardId, quantity, isFoil }) => {
// Check if entry already exists
const existing = await ctx.db
.query("collections")
.withIndex("by_user_card", (q) =>
q.eq("userId", userId).eq("cardId", cardId).eq("isFoil", isFoil)
)
.first();
if (existing) {
// Update quantity
await ctx.db.patch(existing._id, {
quantity: existing.quantity + quantity,
});
return existing._id;
}
// Create new entry
return await ctx.db.insert("collections", {
userId,
cardId,
quantity,
isFoil,
addedAt: Date.now(),
});
},
});
// Alias for addCard
export const add = addCard;
// Update card quantity
export const updateQuantity = mutation({
args: {
entryId: v.id("collections"),
quantity: v.number(),
},
handler: async (ctx, { entryId, quantity }) => {
if (quantity <= 0) {
await ctx.db.delete(entryId);
return null;
}
await ctx.db.patch(entryId, { quantity });
return entryId;
},
});
// Remove card from collection
export const removeCard = mutation({
args: { entryId: v.id("collections") },
handler: async (ctx, { entryId }) => {
await ctx.db.delete(entryId);
},
});
// Alias for removeCard
export const remove = removeCard;
// Decrease quantity by 1
export const decrementQuantity = mutation({
args: { entryId: v.id("collections") },
handler: async (ctx, { entryId }) => {
const entry = await ctx.db.get(entryId);
if (!entry) return null;
if (entry.quantity <= 1) {
await ctx.db.delete(entryId);
return null;
}
await ctx.db.patch(entryId, { quantity: entry.quantity - 1 });
return entryId;
},
});
// Increase quantity by 1
export const incrementQuantity = mutation({
args: { entryId: v.id("collections") },
handler: async (ctx, { entryId }) => {
const entry = await ctx.db.get(entryId);
if (!entry) return null;
await ctx.db.patch(entryId, { quantity: entry.quantity + 1 });
return entryId;
},
});

13
convex/http.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* HTTP routes for Convex Auth.
*/
import { httpRouter } from "convex/server";
import { auth } from "./auth";
const http = httpRouter();
// Add Convex Auth routes
auth.addHttpRoutes(http);
export default http;

58
convex/scanHistory.ts Normal file
View file

@ -0,0 +1,58 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
// Get recent scan history
export const getRecent = query({
args: { userId: v.id("users"), limit: v.optional(v.number()) },
handler: async (ctx, { userId, limit = 50 }) => {
const history = await ctx.db
.query("scanHistory")
.withIndex("by_user", (q) => q.eq("userId", userId))
.order("desc")
.take(limit);
// Enrich with card data
const enriched = await Promise.all(
history.map(async (entry) => {
const card = entry.cardId ? await ctx.db.get(entry.cardId) : null;
return {
...entry,
card,
};
})
);
return enriched;
},
});
// Record a scan
export const record = mutation({
args: {
userId: v.id("users"),
cardId: v.optional(v.id("cards")),
confidence: v.number(),
addedToCollection: v.boolean(),
},
handler: async (ctx, args) => {
return await ctx.db.insert("scanHistory", {
...args,
scannedAt: Date.now(),
});
},
});
// Clear scan history
export const clear = mutation({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const entries = await ctx.db
.query("scanHistory")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
for (const entry of entries) {
await ctx.db.delete(entry._id);
}
},
});

93
convex/schema.ts Normal file
View file

@ -0,0 +1,93 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { authTables } from "@convex-dev/auth/server";
// Override the default users table to store NO personal data (GDPR compliance)
// User profile info (name, email, etc.) must be fetched from Zitadel OIDC userinfo endpoint
const minimalAuthTables = {
...authTables,
// Override users table - only store what's required for auth to function
users: defineTable({
// No name, email, image, or any PII stored
// The auth system needs this table to exist but we strip all profile data
}),
};
export default defineSchema({
...minimalAuthTables,
// Card printings with perceptual hashes
cards: defineTable({
scryfallId: v.string(),
oracleId: v.string(),
name: v.string(),
setCode: v.string(),
collectorNumber: v.string(),
rarity: v.string(),
artist: v.optional(v.string()),
imageUri: v.optional(v.string()),
hash: v.bytes(), // 24-byte perceptual hash
hashVersion: v.number(), // Algorithm version for migrations
updatedAt: v.number(),
})
.index("by_scryfall", ["scryfallId"])
.index("by_oracle", ["oracleId"])
.index("by_name", ["name"])
.index("by_updated", ["updatedAt"]),
// Oracle cards (abstract game cards)
oracles: defineTable({
oracleId: v.string(),
name: v.string(),
manaCost: v.optional(v.string()),
cmc: v.optional(v.number()),
typeLine: v.optional(v.string()),
oracleText: v.optional(v.string()),
colors: v.optional(v.array(v.string())),
colorIdentity: v.optional(v.array(v.string())),
keywords: v.optional(v.array(v.string())),
power: v.optional(v.string()),
toughness: v.optional(v.string()),
})
.index("by_oracle", ["oracleId"])
.index("by_name", ["name"]),
// MTG sets
sets: defineTable({
setId: v.string(),
code: v.string(),
name: v.string(),
setType: v.optional(v.string()),
releasedAt: v.optional(v.string()),
cardCount: v.optional(v.number()),
iconSvgUri: v.optional(v.string()),
})
.index("by_set", ["setId"])
.index("by_code", ["code"]),
// User collections
collections: defineTable({
userId: v.id("users"),
cardId: v.id("cards"),
quantity: v.number(),
isFoil: v.boolean(),
addedAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_user_card", ["userId", "cardId", "isFoil"]),
// Scan history
scanHistory: defineTable({
userId: v.id("users"),
cardId: v.optional(v.id("cards")),
confidence: v.number(),
scannedAt: v.number(),
addedToCollection: v.boolean(),
}).index("by_user", ["userId"]),
// Sync metadata
metadata: defineTable({
key: v.string(),
value: v.string(),
}).index("by_key", ["key"]),
});

20
convex/users.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* User queries and mutations using Convex Auth.
*/
import { query } from "./_generated/server";
import { getAuthUserId } from "@convex-dev/auth/server";
/**
* Get the currently authenticated user.
*/
export const me = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) {
return null;
}
return await ctx.db.get(userId);
},
});