more endpoints for nessa
This commit is contained in:
@@ -185,6 +185,48 @@ const loginSchema = z.object({
|
|||||||
password: z.string().min(1)
|
password: z.string().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const googleSignInSchema = z.object({
|
||||||
|
idToken: z.string().min(1),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const appleSignInSchema = z.object({
|
||||||
|
idToken: z.string().min(1),
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
firstName: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
|
appleUserId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
interface GoogleTokenPayload {
|
||||||
|
iss: string;
|
||||||
|
azp: string;
|
||||||
|
aud: string;
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean;
|
||||||
|
name?: string;
|
||||||
|
picture?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppleTokenPayload {
|
||||||
|
iss: string;
|
||||||
|
aud: string;
|
||||||
|
exp: number;
|
||||||
|
iat: number;
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
email_verified?: boolean | string;
|
||||||
|
is_private_email?: boolean | string;
|
||||||
|
real_user_status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const nessaDbRouter = createTRPCRouter({
|
export const nessaDbRouter = createTRPCRouter({
|
||||||
health: nessaProcedure.query(async () => {
|
health: nessaProcedure.query(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -352,6 +394,446 @@ export const nessaDbRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
googleSignIn: publicProcedure
|
||||||
|
.input(googleSignInSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
// Verify the Google ID token
|
||||||
|
const tokenInfoResponse = await fetch(
|
||||||
|
`https://oauth2.googleapis.com/tokeninfo?id_token=${input.idToken}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tokenInfoResponse.ok) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid Google ID token"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenPayload =
|
||||||
|
(await tokenInfoResponse.json()) as GoogleTokenPayload;
|
||||||
|
|
||||||
|
// Validate the token payload
|
||||||
|
if (
|
||||||
|
tokenPayload.iss !== "accounts.google.com" &&
|
||||||
|
tokenPayload.iss !== "https://accounts.google.com"
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid token issuer"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (tokenPayload.exp < now) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Token has expired"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleUserId = tokenPayload.sub;
|
||||||
|
const email = tokenPayload.email ?? input.email;
|
||||||
|
const firstName =
|
||||||
|
tokenPayload.given_name ?? input.firstName ?? "Google";
|
||||||
|
const lastName = tokenPayload.family_name ?? input.lastName ?? "User";
|
||||||
|
const displayName =
|
||||||
|
tokenPayload.name ?? `${firstName} ${lastName}`.trim();
|
||||||
|
const avatarUrl = tokenPayload.picture ?? null;
|
||||||
|
|
||||||
|
const conn = NessaConnectionFactory();
|
||||||
|
|
||||||
|
// Check if user exists by Google provider ID
|
||||||
|
const existingByGoogle = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM authProviders WHERE provider = 'google' AND providerUserId = ?",
|
||||||
|
args: [googleUserId]
|
||||||
|
});
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
if (existingByGoogle.rows.length > 0) {
|
||||||
|
// User exists with Google account - log them in
|
||||||
|
userId = existingByGoogle.rows[0].userId as string;
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
} else if (email) {
|
||||||
|
// Check if user exists by email
|
||||||
|
const existingByEmail = await conn.execute({
|
||||||
|
sql: "SELECT id FROM users WHERE email = ?",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingByEmail.rows.length > 0) {
|
||||||
|
// User exists with email - link Google account
|
||||||
|
userId = existingByEmail.rows[0].id as string;
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"google",
|
||||||
|
googleUserId,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
avatarUrl
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE users SET provider = 'google', lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new user with Google account
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
args: [
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
tokenPayload.email_verified ? 1 : 0,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
"google",
|
||||||
|
"active"
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"google",
|
||||||
|
googleUserId,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
avatarUrl
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Create default workout plan for new user
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"Getting Started",
|
||||||
|
"strength",
|
||||||
|
"beginner",
|
||||||
|
"strength",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No email available - create user without email
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
args: [
|
||||||
|
userId,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
"google",
|
||||||
|
"active"
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"google",
|
||||||
|
googleUserId,
|
||||||
|
null,
|
||||||
|
displayName,
|
||||||
|
avatarUrl
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Create default workout plan for new user
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"Getting Started",
|
||||||
|
"strength",
|
||||||
|
"beginner",
|
||||||
|
"strength",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await signNessaToken(userId);
|
||||||
|
return { success: true, token, userId };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.error("Failed to sign in with Google:", error);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to sign in with Google"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
appleSignIn: publicProcedure
|
||||||
|
.input(appleSignInSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
// Verify the Apple ID token
|
||||||
|
// Apple's public keys for JWT verification
|
||||||
|
const appleKeysResponse = await fetch(
|
||||||
|
"https://appleid.apple.com/auth/keys"
|
||||||
|
);
|
||||||
|
if (!appleKeysResponse.ok) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to fetch Apple public keys"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appleKeys = (await appleKeysResponse.json()) as {
|
||||||
|
keys: Array<{
|
||||||
|
kty: string;
|
||||||
|
kid: string;
|
||||||
|
use: string;
|
||||||
|
alg: string;
|
||||||
|
n: string;
|
||||||
|
e: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decode the JWT header to get the key ID
|
||||||
|
const [headerB64] = input.idToken.split(".");
|
||||||
|
if (!headerB64) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid Apple ID token format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerJson = Buffer.from(headerB64, "base64url").toString("utf8");
|
||||||
|
const header = JSON.parse(headerJson) as { kid: string; alg: string };
|
||||||
|
|
||||||
|
// Find the matching key
|
||||||
|
const key = appleKeys.keys.find((k) => k.kid === header.kid);
|
||||||
|
if (!key) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Apple public key not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For simplicity, we'll decode the payload and verify basic claims
|
||||||
|
// In production, you should use a proper JWT library like jose to verify the signature
|
||||||
|
const [, payloadB64] = input.idToken.split(".");
|
||||||
|
if (!payloadB64) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid Apple ID token format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadJson = Buffer.from(payloadB64, "base64url").toString(
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const tokenPayload = JSON.parse(payloadJson) as AppleTokenPayload;
|
||||||
|
|
||||||
|
// Validate the token payload
|
||||||
|
if (tokenPayload.iss !== "https://appleid.apple.com") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid token issuer"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (tokenPayload.exp < now) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Token has expired"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple user ID from token should match the one provided
|
||||||
|
if (tokenPayload.sub !== input.appleUserId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Apple user ID mismatch"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appleUserId = tokenPayload.sub;
|
||||||
|
// Apple only sends email on first sign-in, so use input.email if token doesn't have it
|
||||||
|
const email = tokenPayload.email ?? input.email;
|
||||||
|
const firstName = input.firstName ?? "Apple";
|
||||||
|
const lastName = input.lastName ?? "User";
|
||||||
|
const displayName = `${firstName} ${lastName}`.trim();
|
||||||
|
|
||||||
|
const conn = NessaConnectionFactory();
|
||||||
|
|
||||||
|
// Check if user exists by Apple provider ID
|
||||||
|
const existingByApple = await conn.execute({
|
||||||
|
sql: "SELECT userId FROM authProviders WHERE provider = 'apple' AND providerUserId = ?",
|
||||||
|
args: [appleUserId]
|
||||||
|
});
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
if (existingByApple.rows.length > 0) {
|
||||||
|
// User exists with Apple account - log them in
|
||||||
|
userId = existingByApple.rows[0].userId as string;
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE users SET lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||||
|
args: [userId]
|
||||||
|
});
|
||||||
|
} else if (email) {
|
||||||
|
// Check if user exists by email
|
||||||
|
const existingByEmail = await conn.execute({
|
||||||
|
sql: "SELECT id FROM users WHERE email = ?",
|
||||||
|
args: [email]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingByEmail.rows.length > 0) {
|
||||||
|
// User exists with email - link Apple account
|
||||||
|
userId = existingByEmail.rows[0].id as string;
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"apple",
|
||||||
|
appleUserId,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await conn.execute({
|
||||||
|
sql: "UPDATE users SET provider = 'apple', appleUserId = ?, lastLoginAt = datetime('now'), updatedAt = datetime('now') WHERE id = ?",
|
||||||
|
args: [appleUserId, userId]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new user with Apple account
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
args: [
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
tokenPayload.email_verified === true ||
|
||||||
|
tokenPayload.email_verified === "true"
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
displayName,
|
||||||
|
null,
|
||||||
|
"apple",
|
||||||
|
appleUserId,
|
||||||
|
"active"
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"apple",
|
||||||
|
appleUserId,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Create default workout plan for new user
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"Getting Started",
|
||||||
|
"strength",
|
||||||
|
"beginner",
|
||||||
|
"strength",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No email available - create user without email
|
||||||
|
userId = crypto.randomUUID();
|
||||||
|
await conn.execute({
|
||||||
|
sql: `INSERT INTO users (id, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, appleUserId, status, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
args: [
|
||||||
|
userId,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
displayName,
|
||||||
|
null,
|
||||||
|
"apple",
|
||||||
|
appleUserId,
|
||||||
|
"active"
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO authProviders (id, userId, provider, providerUserId, email, displayName, avatarUrl) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"apple",
|
||||||
|
appleUserId,
|
||||||
|
null,
|
||||||
|
displayName,
|
||||||
|
null
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Create default workout plan for new user
|
||||||
|
await conn.execute({
|
||||||
|
sql: "INSERT INTO workoutPlans (id, userId, name, category, difficulty, type, isPublic) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
"Getting Started",
|
||||||
|
"strength",
|
||||||
|
"beginner",
|
||||||
|
"strength",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await signNessaToken(userId);
|
||||||
|
return { success: true, token, userId };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof TRPCError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.error("Failed to sign in with Apple:", error);
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Failed to sign in with Apple"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
getUsers: nessaProcedure
|
getUsers: nessaProcedure
|
||||||
.input(paginatedQuerySchema)
|
.input(paginatedQuerySchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user