diff --git a/app/api/accounts/artists/route.ts b/app/api/accounts/artists/route.ts new file mode 100644 index 00000000..eef8b0e6 --- /dev/null +++ b/app/api/accounts/artists/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAddArtistBody, type AddArtistBody } from "@/lib/accounts/validateAddArtistBody"; +import { addArtistToAccountHandler } from "@/lib/accounts/addArtistToAccountHandler"; + +/** + * POST /api/accounts/artists + * + * Add an artist to an account's list of associated artists. + * If the artist is already associated with the account, returns success. + * + * @param req - The incoming request with email and artistId in body + * @returns NextResponse with success status or error + */ +export async function POST(req: NextRequest) { + const body = await safeParseJson(req); + + const validated = validateAddArtistBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + return addArtistToAccountHandler(validated as AddArtistBody); +} + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns NextResponse with CORS headers + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/accounts/route.ts b/app/api/accounts/route.ts new file mode 100644 index 00000000..6bdebbfa --- /dev/null +++ b/app/api/accounts/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { + validateCreateAccountBody, + type CreateAccountBody, +} from "@/lib/accounts/validateCreateAccountBody"; +import { + validateUpdateAccountBody, + type UpdateAccountBody, +} from "@/lib/accounts/validateUpdateAccountBody"; +import { createAccountHandler } from "@/lib/accounts/createAccountHandler"; +import { updateAccountHandler } from "@/lib/accounts/updateAccountHandler"; + +/** + * POST /api/accounts + * + * Create a new account or retrieve an existing account by email or wallet. + * If an account with the provided email or wallet exists, returns that account. + * Otherwise creates a new account and initializes credits. + * + * @param req - The incoming request with email and/or wallet in body + * @returns Account data + */ +export async function POST(req: NextRequest) { + const body = await safeParseJson(req); + + const validated = validateCreateAccountBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + return createAccountHandler(validated as CreateAccountBody); +} + +/** + * PATCH /api/accounts + * + * Update an existing account's profile information. + * Requires accountId in the body along with fields to update. + * + * @param req - The incoming request with accountId and update fields + * @returns NextResponse with updated account data or error + */ +export async function PATCH(req: NextRequest) { + const body = await safeParseJson(req); + + const validated = validateUpdateAccountBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + return updateAccountHandler(validated as UpdateAccountBody); +} + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns NextResponse with CORS headers + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/accounts/addArtistToAccountHandler.ts b/lib/accounts/addArtistToAccountHandler.ts new file mode 100644 index 00000000..88de7650 --- /dev/null +++ b/lib/accounts/addArtistToAccountHandler.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { selectAccountByEmail } from "@/lib/supabase/account_emails/selectAccountByEmail"; +import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccountArtistIds"; +import { insertAccountArtistId } from "@/lib/supabase/account_artist_ids/insertAccountArtistId"; +import type { AddArtistBody } from "./validateAddArtistBody"; + +/** + * Handles POST /api/accounts/artists - Add artist to account's artist list. + * + * @param body - Validated request body with email and artistId + * @returns NextResponse with success status + */ +export async function addArtistToAccountHandler(body: AddArtistBody): Promise { + const { email, artistId } = body; + + try { + // Find account by email + const accountEmail = await selectAccountByEmail(email); + if (!accountEmail?.account_id) { + return NextResponse.json( + { message: "Not found account." }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const accountId = accountEmail.account_id; + + // Check if artist is already associated with account + const existingArtists = await getAccountArtistIds({ accountIds: [accountId] }); + const alreadyExists = existingArtists.some(a => a.artist_id === artistId); + + if (alreadyExists) { + return NextResponse.json({ success: true }, { status: 200, headers: getCorsHeaders() }); + } + + // Add artist to account + await insertAccountArtistId(accountId, artistId); + + return NextResponse.json({ success: true }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + const message = error instanceof Error ? error.message : "failed"; + return NextResponse.json({ message }, { status: 400, headers: getCorsHeaders() }); + } +} diff --git a/lib/accounts/createAccountHandler.ts b/lib/accounts/createAccountHandler.ts new file mode 100644 index 00000000..6f7578de --- /dev/null +++ b/lib/accounts/createAccountHandler.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { selectAccountByEmail } from "@/lib/supabase/account_emails/selectAccountByEmail"; +import { selectAccountByWallet } from "@/lib/supabase/account_wallets/selectAccountByWallet"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { insertAccountEmail } from "@/lib/supabase/account_emails/insertAccountEmail"; +import { insertAccountWallet } from "@/lib/supabase/account_wallets/insertAccountWallet"; +import { insertCreditsUsage } from "@/lib/supabase/credits_usage/insertCreditsUsage"; +import { assignAccountToOrg } from "@/lib/organizations/assignAccountToOrg"; +import type { CreateAccountBody } from "./validateCreateAccountBody"; + +/** + * Account data response shape. + */ +export type AccountDataResponse = { + id: string; + account_id: string; + name?: string; + email?: string; + wallet?: string; + image?: string; + instruction?: string; + organization?: string; +}; + +/** + * Handles POST /api/accounts - Create or retrieve account by email/wallet. + * + * @param body - Validated request body with email and/or wallet + * @returns NextResponse with account data + */ +export async function createAccountHandler(body: CreateAccountBody): Promise { + const { email, wallet } = body; + + try { + // If email is provided, check account_emails + if (email) { + const emailRecord = await selectAccountByEmail(email); + if (emailRecord?.account_id) { + // Assign to org based on email domain (idempotent) + await assignAccountToOrg(emailRecord.account_id, email); + + const accountData = await getAccountWithDetails(emailRecord.account_id); + if (accountData) { + return NextResponse.json( + { data: accountData }, + { status: 200, headers: getCorsHeaders() }, + ); + } + } + } + + // If wallet is provided, check account_wallets + if (wallet) { + try { + const account = await selectAccountByWallet(wallet); + + // Flatten the nested relations into a single object + const accountInfo = account.account_info?.[0]; + const accountEmail = account.account_emails?.[0]; + const accountWallet = account.account_wallets?.[0]; + + const accountData: AccountDataResponse = { + id: account.id, + account_id: account.id, + name: account.name || undefined, + image: accountInfo?.image || undefined, + instruction: accountInfo?.instruction || undefined, + organization: accountInfo?.organization || undefined, + email: accountEmail?.email || undefined, + wallet: accountWallet?.wallet || undefined, + }; + + return NextResponse.json({ data: accountData }, { status: 200, headers: getCorsHeaders() }); + } catch { + // Wallet not found, continue to create new account + } + } + + // Create new account + const newAccount = await insertAccount({ name: "" }); + + if (email) { + await insertAccountEmail(newAccount.id, email); + // Assign new account to org based on email domain + await assignAccountToOrg(newAccount.id, email); + } + + if (wallet) { + await insertAccountWallet(newAccount.id, wallet); + } + + await insertCreditsUsage(newAccount.id); + + const newAccountData: AccountDataResponse = { + id: newAccount.id, + account_id: newAccount.id, + email: email || "", + wallet: wallet || "", + image: "", + instruction: "", + organization: "", + }; + + return NextResponse.json({ data: newAccountData }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + const message = error instanceof Error ? error.message : "failed"; + return NextResponse.json({ message }, { status: 400, headers: getCorsHeaders() }); + } +} diff --git a/lib/accounts/updateAccountHandler.ts b/lib/accounts/updateAccountHandler.ts new file mode 100644 index 00000000..cacb7d51 --- /dev/null +++ b/lib/accounts/updateAccountHandler.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAccountWithDetails } from "@/lib/supabase/accounts/getAccountWithDetails"; +import { updateAccount } from "@/lib/supabase/accounts/updateAccount"; +import { insertAccountInfo } from "@/lib/supabase/account_info/insertAccountInfo"; +import { updateAccountInfo } from "@/lib/supabase/account_info/updateAccountInfo"; +import { selectAccountInfo } from "@/lib/supabase/account_info/selectAccountInfo"; +import type { UpdateAccountBody } from "./validateUpdateAccountBody"; + +/** + * Handles PATCH /api/accounts - Update account profile information. + * + * @param body - Validated request body with accountId and fields to update + * @returns NextResponse with updated account data + */ +export async function updateAccountHandler(body: UpdateAccountBody): Promise { + const { + accountId, + name, + instruction, + organization, + image, + jobTitle, + roleType, + companyName, + knowledges, + } = body; + + try { + // Verify account exists + const found = await getAccountWithDetails(accountId); + if (!found) { + return NextResponse.json( + { data: null, message: "Account not found" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + // Update account name if provided + if (name !== undefined) { + await updateAccount(accountId, { name }); + } + + // Check if account_info exists + const existingInfo = await selectAccountInfo(accountId); + + if (!existingInfo) { + // Create new account_info record + await insertAccountInfo({ + account_id: accountId, + organization, + image, + instruction, + job_title: jobTitle, + role_type: roleType, + company_name: companyName, + knowledges, + }); + } else { + // Update existing account_info + await updateAccountInfo(accountId, { + organization, + image, + instruction, + job_title: jobTitle, + role_type: roleType, + company_name: companyName, + knowledges, + }); + } + + // Fetch the updated account with all joined info + const updated = await getAccountWithDetails(accountId); + + return NextResponse.json({ data: updated }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + const message = error instanceof Error ? error.message : "failed"; + return NextResponse.json({ message }, { status: 400, headers: getCorsHeaders() }); + } +} diff --git a/lib/accounts/validateAddArtistBody.ts b/lib/accounts/validateAddArtistBody.ts new file mode 100644 index 00000000..5f3e599b --- /dev/null +++ b/lib/accounts/validateAddArtistBody.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const addArtistBodySchema = z.object({ + email: z.string().email("email must be a valid email address"), + artistId: z.string().uuid("artistId must be a valid UUID"), +}); + +export type AddArtistBody = z.infer; + +/** + * Validates request body for POST /api/accounts/artists. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateAddArtistBody(body: unknown): NextResponse | AddArtistBody { + const result = addArtistBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/accounts/validateCreateAccountBody.ts b/lib/accounts/validateCreateAccountBody.ts new file mode 100644 index 00000000..0cbd5356 --- /dev/null +++ b/lib/accounts/validateCreateAccountBody.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createAccountBodySchema = z.object({ + email: z.string().email("email must be a valid email address").optional(), + wallet: z.string().min(1, "wallet cannot be empty").optional(), +}); + +export type CreateAccountBody = z.infer; + +/** + * Validates request body for POST /api/accounts. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateCreateAccountBody(body: unknown): NextResponse | CreateAccountBody { + const result = createAccountBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/accounts/validateUpdateAccountBody.ts b/lib/accounts/validateUpdateAccountBody.ts new file mode 100644 index 00000000..35a06b3c --- /dev/null +++ b/lib/accounts/validateUpdateAccountBody.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const updateAccountBodySchema = z.object({ + accountId: z.string().uuid("accountId must be a valid UUID"), + name: z.string().optional(), + instruction: z.string().optional(), + organization: z.string().optional(), + image: z.string().url("image must be a valid URL").optional().or(z.literal("")), + jobTitle: z.string().optional(), + roleType: z.string().optional(), + companyName: z.string().optional(), + knowledges: z.array(z.string()).optional(), +}); + +export type UpdateAccountBody = z.infer; + +/** + * Validates request body for PATCH /api/accounts. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body if validation passes. + */ +export function validateUpdateAccountBody(body: unknown): NextResponse | UpdateAccountBody { + const result = updateAccountBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/email/extractDomain.ts b/lib/email/extractDomain.ts new file mode 100644 index 00000000..03938abe --- /dev/null +++ b/lib/email/extractDomain.ts @@ -0,0 +1,18 @@ +/** + * Extracts the domain from an email address. + * + * @param email - The email address to extract domain from + * @returns The domain portion of the email, or null if invalid + */ +export function extractDomain(email: string): string | null { + if (!email || typeof email !== "string") { + return null; + } + + const parts = email.split("@"); + if (parts.length !== 2 || !parts[1]) { + return null; + } + + return parts[1].toLowerCase(); +} diff --git a/lib/organizations/assignAccountToOrg.ts b/lib/organizations/assignAccountToOrg.ts new file mode 100644 index 00000000..e4b81fbe --- /dev/null +++ b/lib/organizations/assignAccountToOrg.ts @@ -0,0 +1,24 @@ +import { extractDomain } from "@/lib/email/extractDomain"; +import { selectOrgByDomain } from "@/lib/supabase/organization_domains/selectOrgByDomain"; +import { addAccountToOrganization } from "@/lib/supabase/account_organization_ids/addAccountToOrganization"; + +/** + * Assign an account to their organization based on email domain. + * Called on login/account creation to ensure accounts are linked to their org. + * + * @param accountId - The account ID + * @param email - The account's email address + * @returns The org ID if assigned, null otherwise + */ +export async function assignAccountToOrg(accountId: string, email: string): Promise { + if (!accountId || !email) return null; + + const domain = extractDomain(email); + if (!domain) return null; + + const orgId = await selectOrgByDomain(domain); + if (!orgId) return null; + + await addAccountToOrganization(accountId, orgId); + return orgId; +} diff --git a/lib/supabase/account_emails/insertAccountEmail.ts b/lib/supabase/account_emails/insertAccountEmail.ts new file mode 100644 index 00000000..711fc850 --- /dev/null +++ b/lib/supabase/account_emails/insertAccountEmail.ts @@ -0,0 +1,30 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Inserts a new account_emails record linking an email to an account. + * + * @param accountId - The account ID to link the email to + * @param email - The email address to insert + * @returns The inserted account_emails record, or null if failed + */ +export async function insertAccountEmail( + accountId: string, + email: string, +): Promise | null> { + const { data, error } = await supabase + .from("account_emails") + .insert({ + account_id: accountId, + email, + }) + .select("*") + .single(); + + if (error) { + console.error("[ERROR] insertAccountEmail:", error); + return null; + } + + return data || null; +} diff --git a/lib/supabase/account_emails/selectAccountByEmail.ts b/lib/supabase/account_emails/selectAccountByEmail.ts new file mode 100644 index 00000000..0c90a70c --- /dev/null +++ b/lib/supabase/account_emails/selectAccountByEmail.ts @@ -0,0 +1,24 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Selects an account_emails record by email address. + * + * @param email - The email address to look up + * @returns The account_emails record if found, null otherwise + */ +export async function selectAccountByEmail( + email: string, +): Promise | null> { + const { data, error } = await supabase + .from("account_emails") + .select("*") + .eq("email", email) + .single(); + + if (error) { + return null; + } + + return data || null; +} diff --git a/lib/supabase/account_wallets/insertAccountWallet.ts b/lib/supabase/account_wallets/insertAccountWallet.ts new file mode 100644 index 00000000..05702f50 --- /dev/null +++ b/lib/supabase/account_wallets/insertAccountWallet.ts @@ -0,0 +1,34 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Inserts a new account_wallets record linking a wallet to an account. + * + * @param accountId - The account ID to link the wallet to + * @param wallet - The wallet address to insert + * @returns The inserted account_wallets record + * @throws Error if insert fails + */ +export async function insertAccountWallet( + accountId: string, + wallet: string, +): Promise> { + const { data, error } = await supabase + .from("account_wallets") + .insert({ + account_id: accountId, + wallet, + }) + .select("*") + .single(); + + if (error) { + throw new Error(`Error inserting wallet: ${error.message}`); + } + + if (!data) { + throw new Error("Error inserting wallet: No data returned"); + } + + return data; +} diff --git a/lib/supabase/account_wallets/selectAccountByWallet.ts b/lib/supabase/account_wallets/selectAccountByWallet.ts new file mode 100644 index 00000000..be5132c1 --- /dev/null +++ b/lib/supabase/account_wallets/selectAccountByWallet.ts @@ -0,0 +1,43 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Raw account data with nested relations from Supabase join query. + */ +export type AccountWithRelations = Tables<"accounts"> & { + account_info: Tables<"account_info">[] | null; + account_emails: Tables<"account_emails">[] | null; + account_wallets: Tables<"account_wallets">[] | null; +}; + +/** + * Selects an account by wallet address. + * Returns the account with related info, emails, and wallets. + * + * @param wallet - The wallet address to look up + * @returns The account with relations if found, null otherwise + * @throws Error if wallet not found or account lookup fails + */ +export async function selectAccountByWallet(wallet: string): Promise { + const { data: walletFound, error: walletError } = await supabase + .from("account_wallets") + .select("*") + .eq("wallet", wallet) + .single(); + + if (walletError || !walletFound) { + throw new Error("No account found with this wallet address"); + } + + const { data: account, error: accountError } = await supabase + .from("accounts") + .select("*, account_info(*), account_emails(*), account_wallets(*)") + .eq("id", walletFound.account_id) + .single(); + + if (accountError || !account) { + throw new Error("Error fetching account details"); + } + + return account as AccountWithRelations; +} diff --git a/lib/supabase/credits_usage/insertCreditsUsage.ts b/lib/supabase/credits_usage/insertCreditsUsage.ts new file mode 100644 index 00000000..78df4956 --- /dev/null +++ b/lib/supabase/credits_usage/insertCreditsUsage.ts @@ -0,0 +1,34 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** Default credits for free tier accounts */ +const DEFAULT_CREDITS = 25; + +/** + * Inserts a new credits_usage record for an account. + * Initializes with default credits. + * + * @param accountId - The account ID to initialize credits for + * @param remainingCredits - Optional override for initial credits (defaults to 25) + * @returns The inserted credits_usage record, or null if failed + */ +export async function insertCreditsUsage( + accountId: string, + remainingCredits: number = DEFAULT_CREDITS, +): Promise | null> { + const { data, error } = await supabase + .from("credits_usage") + .insert({ + account_id: accountId, + remaining_credits: remainingCredits, + }) + .select("*") + .single(); + + if (error) { + console.error("[ERROR] insertCreditsUsage:", error); + return null; + } + + return data || null; +} diff --git a/lib/supabase/organization_domains/selectOrgByDomain.ts b/lib/supabase/organization_domains/selectOrgByDomain.ts new file mode 100644 index 00000000..5741f6e2 --- /dev/null +++ b/lib/supabase/organization_domains/selectOrgByDomain.ts @@ -0,0 +1,21 @@ +import supabase from "../serverClient"; + +/** + * Looks up an organization ID by email domain. + * + * @param domain - The email domain to look up (e.g., "company.com") + * @returns The organization ID if found, null otherwise + */ +export async function selectOrgByDomain(domain: string): Promise { + const { data, error } = await supabase + .from("organization_domains") + .select("organization_id") + .eq("domain", domain) + .single(); + + if (error || !data) { + return null; + } + + return data.organization_id; +}