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:
parent
56499d5af9
commit
83ab4df537
138 changed files with 19136 additions and 7681 deletions
59
convex/_generated/api.d.ts
vendored
Normal file
59
convex/_generated/api.d.ts
vendored
Normal 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
23
convex/_generated/api.js
Normal 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
60
convex/_generated/dataModel.d.ts
vendored
Normal 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
143
convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
93
convex/_generated/server.js
Normal file
93
convex/_generated/server.js
Normal 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
48
convex/auth.ts
Normal 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
146
convex/cards.ts
Normal 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
166
convex/collections.ts
Normal 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
13
convex/http.ts
Normal 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
58
convex/scanHistory.ts
Normal 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
93
convex/schema.ts
Normal 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
20
convex/users.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue