diff --git a/apps/api/v1/index.ts b/apps/api/v1/index.ts index d00028b1..9153d78b 100644 --- a/apps/api/v1/index.ts +++ b/apps/api/v1/index.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { authMiddleware } from './middlewares/auth' import authApp from './routes/auth' import budgetsApp from './routes/budgets' +import transactionsApp from './routes/transactions' import usersApp from './routes/users' import walletsApp from './routes/wallets' @@ -12,4 +13,5 @@ hono.use('*', authMiddleware) hono.route('/auth', authApp) hono.route('/budgets', budgetsApp) hono.route('/users', usersApp) +hono.route('/transactions', transactionsApp) hono.route('/wallets', walletsApp) diff --git a/apps/api/v1/routes/transactions.ts b/apps/api/v1/routes/transactions.ts new file mode 100644 index 00000000..7e3d1d49 --- /dev/null +++ b/apps/api/v1/routes/transactions.ts @@ -0,0 +1,95 @@ +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { z } from 'zod' +import { getAuthUserStrict } from '../middlewares/auth' +import { canUserReadBudget, findBudget } from '../services/budget.service' +import { + canUserCreateTransaction, + createTransaction, +} from '../services/transaction.service' +import { findUserWallet } from '../services/wallet.service' +import { + zCreateTransaction, + zUpdateTransaction, +} from '../validation/transaction.zod' + +const router = new Hono() + +router.get( + '/', + zValidator( + 'query', + z.object({ + order_by: z.enum(['date']).optional(), + order: z.enum(['asc', 'desc']).optional(), + wallet_id: z.string().optional(), + budget_id: z.string().optional(), + from_date: z.string().optional(), + to_date: z.string().optional(), + take: z.number().optional(), + skip: z.number().optional(), + cursor: z.string().optional(), + }), + ), + async (c) => { + return c.json([]) + }, +) + +router.post('/', zValidator('json', zCreateTransaction), async (c) => { + const user = getAuthUserStrict(c) + const data = c.req.valid('json') + const { budgetId, walletAccountId: walletId } = data + + const budget = budgetId ? await findBudget({ budgetId }) : null + if (budgetId && (!budget || !(await canUserReadBudget({ user, budget })))) { + return c.json({ message: 'budget not found' }, 404) + } + + const wallet = await findUserWallet({ user, walletId }) + if (!wallet) { + return c.json({ message: 'wallet not found' }, 404) + } + + if ( + !(await canUserCreateTransaction({ user, budget, walletAccount: wallet })) + ) { + return c.json({ message: 'user cannot create transaction' }, 403) + } + + const transaction = await createTransaction({ + user, + data, + }) + + return c.json(transaction, 201) +}) + +router.put( + '/:transactionId', + zValidator( + 'param', + z.object({ + transactionId: z.string(), + }), + ), + zValidator('json', zUpdateTransaction), + async (c) => { + return c.json({ message: 'not implemented' }) + }, +) + +router.delete( + '/:transactionId', + zValidator( + 'param', + z.object({ + transactionId: z.string(), + }), + ), + async (c) => { + return c.json({ message: 'not implemented' }) + }, +) + +export default router diff --git a/apps/api/v1/services/transaction.service.ts b/apps/api/v1/services/transaction.service.ts new file mode 100644 index 00000000..db27d535 --- /dev/null +++ b/apps/api/v1/services/transaction.service.ts @@ -0,0 +1,43 @@ +import prisma from '@/lib/prisma' +import type { Budget, User, UserWalletAccount } from '@prisma/client' +import type { CreateTransaction } from '../validation/transaction.zod' +import { isUserBudgetMember } from './budget.service' + +export async function canUserCreateTransaction({ + user, + budget, + walletAccount, +}: { + user: User + budget: Budget | null + walletAccount: UserWalletAccount | null +}) { + // If budget is provided, user must be a member of the budget + if (budget && !(await isUserBudgetMember({ user, budget }))) { + return false + } + + // If wallet is provided, user must own the wallet + if (walletAccount && walletAccount.userId !== user.id) { + return false + } + + return true +} + +export async function createTransaction({ + user, + data, +}: { + user: User + data: CreateTransaction +}) { + const transaction = await prisma.transaction.create({ + data: { + ...data, + createdByUserId: user.id, + }, + }) + + return transaction +} diff --git a/apps/api/v1/validation/transaction.zod.ts b/apps/api/v1/validation/transaction.zod.ts new file mode 100644 index 00000000..42112619 --- /dev/null +++ b/apps/api/v1/validation/transaction.zod.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const zCreateTransaction = z.object({ + date: z.date(), + amount: z.number(), + currency: z.string(), + note: z.string().optional(), + budgetId: z.string().optional(), + walletAccountId: z.string(), +}) +export type CreateTransaction = z.infer + +export const zUpdateTransaction = z.object({ + date: z.date().optional(), + amount: z.number().optional(), + currency: z.string().optional(), + note: z.string().optional(), + budgetId: z.string().optional(), + walletId: z.string().optional(), +}) +export type UpdateTransaction = z.infer diff --git a/biome.json b/biome.json index 0829f715..4468ab82 100644 --- a/biome.json +++ b/biome.json @@ -1,293 +1,301 @@ { - "$schema": "https://biomejs.dev/schemas/1.8.0/schema.json", - "formatter": { - "enabled": true, - "formatWithErrors": false, - "indentStyle": "space", - "indentWidth": 2, - "lineEnding": "lf", - "lineWidth": 80, - "attributePosition": "auto" - }, - "organizeImports": { "enabled": true }, - "linter": { - "enabled": true, - "rules": { - "recommended": false, - "a11y": { - "noAccessKey": "error", - "noAriaUnsupportedElements": "error", - "noAutofocus": "error", - "noBlankTarget": "error", - "noDistractingElements": "error", - "noHeaderScope": "error", - "noInteractiveElementToNoninteractiveRole": "error", - "noNoninteractiveElementToInteractiveRole": "error", - "noNoninteractiveTabindex": "error", - "noPositiveTabindex": "error", - "noRedundantAlt": "error", - "noRedundantRoles": "error", - "useAltText": "error", - "useAnchorContent": "error", - "useAriaActivedescendantWithTabindex": "error", - "useAriaPropsForRole": "error", - "useButtonType": "error", - "useHeadingContent": "error", - "useHtmlLang": "error", - "useIframeTitle": "error", - "useKeyWithClickEvents": "error", - "useKeyWithMouseEvents": "error", - "useMediaCaption": "error", - "useValidAnchor": "error", - "useValidAriaProps": "error", - "useValidAriaRole": { - "level": "error", - "options": { "allowInvalidRoles": [], "ignoreNonDom": false } - }, - "useValidAriaValues": "error", - "useValidLang": "error" - }, - "complexity": { - "noExtraBooleanCast": "error", - "noMultipleSpacesInRegularExpressionLiterals": "error", - "noUselessCatch": "error", - "noUselessConstructor": "error", - "noUselessFragments": "error", - "noUselessLabel": "error", - "noUselessLoneBlockStatements": "error", - "noUselessRename": "error", - "noUselessTernary": "error", - "noVoid": "error", - "noWith": "error", - "useArrowFunction": "off", - "useLiteralKeys": "error", - "useRegexLiterals": "error" - }, - "correctness": { - "noUnusedImports": "warn", - "noChildrenProp": "error", - "noConstAssign": "error", - "noConstantCondition": "warn", - "noConstructorReturn": "error", - "noEmptyCharacterClassInRegex": "error", - "noEmptyPattern": "error", - "noGlobalObjectCalls": "error", - "noInnerDeclarations": "error", - "noInvalidConstructorSuper": "error", - "noInvalidUseBeforeDeclaration": "off", - "noNewSymbol": "error", - "noNodejsModules": "off", - "noNonoctalDecimalEscape": "error", - "noPrecisionLoss": "error", - "noSelfAssign": "error", - "noSetterReturn": "error", - "noSwitchDeclarations": "error", - "noUndeclaredVariables": "error", - "noUnreachable": "error", - "noUnreachableSuper": "error", - "noUnsafeFinally": "error", - "noUnsafeOptionalChaining": "error", - "noUnusedLabels": "error", - "noUnusedPrivateClassMembers": "off", - "noUnusedVariables": "warn", - "noVoidElementsWithChildren": "error", - "useArrayLiterals": "off", - "useExhaustiveDependencies": "warn", - "useHookAtTopLevel": "error", - "useIsNan": "error", - "useJsxKeyInIterable": "error", - "useValidForDirection": "error", - "useYield": "error" - }, - "security": { - "noDangerouslySetInnerHtml": "warn", - "noDangerouslySetInnerHtmlWithChildren": "error", - "noGlobalEval": "error" - }, - "style": { - "noArguments": "error", - "noCommaOperator": "error", - "noDefaultExport": "off", - "noImplicitBoolean": "error", - "noNegationElse": "off", - "noParameterAssign": "error", - "noRestrictedGlobals": { - "level": "error", - "options": { - "deniedGlobals": [ - "isFinite", - "isNaN", - "addEventListener", - "blur", - "close", - "closed", - "confirm", - "defaultStatus", - "defaultstatus", - "event", - "external", - "find", - "focus", - "frameElement", - "frames", - "history", - "innerHeight", - "innerWidth", - "length", - "location", - "locationbar", - "menubar", - "moveBy", - "moveTo", - "name", - "onblur", - "onerror", - "onfocus", - "onload", - "onresize", - "onunload", - "open", - "opener", - "opera", - "outerHeight", - "outerWidth", - "pageXOffset", - "pageYOffset", - "parent", - "print", - "removeEventListener", - "resizeBy", - "resizeTo", - "screen", - "screenLeft", - "screenTop", - "screenX", - "screenY", - "scroll", - "scrollbars", - "scrollBy", - "scrollTo", - "scrollX", - "scrollY", - "self", - "status", - "statusbar", - "stop", - "toolbar", - "top" - ] - } - }, - "noUselessElse": "error", - "noVar": "error", - "useBlockStatements": "warn", - "useCollapsedElseIf": "error", - "useConst": "error", - "useDefaultParameterLast": "error", - "useExponentiationOperator": "error", - "useFragmentSyntax": "error", - "useImportType": "warn", - "useNamingConvention": { - "level": "error", - "options": { - "strictCase": false, - "conventions": [ - { - "selector": { "kind": "function" }, - "formats": ["camelCase", "PascalCase"] - }, - { - "selector": { "kind": "variable" }, - "formats": ["camelCase", "PascalCase", "CONSTANT_CASE"] - }, - { "selector": { "kind": "typeLike" }, "formats": ["PascalCase"] } - ] - } - }, - "useNumericLiterals": "error", - "useShorthandAssign": "error", - "useSingleVarDeclarator": "error", - "useTemplate": "error" - }, - "suspicious": { - "noArrayIndexKey": "error", - "noAssignInExpressions": "error", - "noAsyncPromiseExecutor": "error", - "noCatchAssign": "error", - "noClassAssign": "error", - "noCommentText": "error", - "noCompareNegZero": "error", - "noConfusingLabels": "error", - "noConsoleLog": "warn", - "noControlCharactersInRegex": "error", - "noDebugger": "error", - "noDoubleEquals": "error", - "noDuplicateCase": "error", - "noDuplicateClassMembers": "error", - "noDuplicateJsxProps": "error", - "noDuplicateObjectKeys": "error", - "noDuplicateParameters": "error", - "noEmptyBlockStatements": "error", - "noExplicitAny": "error", - "noFallthroughSwitchClause": "error", - "noFunctionAssign": "error", - "noGlobalAssign": "error", - "noImportAssign": "error", - "noLabelVar": "error", - "noMisleadingCharacterClass": "error", - "noPrototypeBuiltins": "error", - "noRedeclare": "error", - "noSelfCompare": "error", - "noShadowRestrictedNames": "error", - "noUnsafeNegation": "error", - "useAwait": "off", - "useDefaultSwitchClauseLast": "error", - "useGetterReturn": "error", - "useValidTypeof": "error" - } - } - }, - "overrides": [ - { - "include": ["*.ts", "*.tsx"], - "linter": { - "rules": { - "correctness": { - "noConstAssign": "off", - "noGlobalObjectCalls": "off", - "noInvalidConstructorSuper": "off", - "noNewSymbol": "off", - "noSetterReturn": "off", - "noUndeclaredVariables": "off", - "noUnreachable": "off", - "noUnreachableSuper": "off" - }, - "suspicious": { - "noDuplicateClassMembers": "off", - "noDuplicateObjectKeys": "off", - "noDuplicateParameters": "off", - "noFunctionAssign": "off", - "noImportAssign": "off", - "noRedeclare": "off", - "noUnsafeNegation": "off", - "useGetterReturn": "off", - "useValidTypeof": "off" - } - } - } - } - ], - "javascript": { - "formatter": { - "jsxQuoteStyle": "double", - "quoteProperties": "asNeeded", - "trailingCommas": "all", - "semicolons": "asNeeded", - "arrowParentheses": "always", - "bracketSpacing": true, - "bracketSameLine": false, - "quoteStyle": "single", - "attributePosition": "auto" - } - } + "$schema": "https://biomejs.dev/schemas/1.8.0/schema.json", + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 80, + "attributePosition": "auto" + }, + "organizeImports": { "enabled": true }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "a11y": { + "noAccessKey": "error", + "noAriaUnsupportedElements": "error", + "noAutofocus": "error", + "noBlankTarget": "error", + "noDistractingElements": "error", + "noHeaderScope": "error", + "noInteractiveElementToNoninteractiveRole": "error", + "noNoninteractiveElementToInteractiveRole": "error", + "noNoninteractiveTabindex": "error", + "noPositiveTabindex": "error", + "noRedundantAlt": "error", + "noRedundantRoles": "error", + "useAltText": "error", + "useAnchorContent": "error", + "useAriaActivedescendantWithTabindex": "error", + "useAriaPropsForRole": "error", + "useButtonType": "error", + "useHeadingContent": "error", + "useHtmlLang": "error", + "useIframeTitle": "error", + "useKeyWithClickEvents": "error", + "useKeyWithMouseEvents": "error", + "useMediaCaption": "error", + "useValidAnchor": "error", + "useValidAriaProps": "error", + "useValidAriaRole": { + "level": "error", + "options": { "allowInvalidRoles": [], "ignoreNonDom": false } + }, + "useValidAriaValues": "error", + "useValidLang": "error" + }, + "complexity": { + "noExtraBooleanCast": "error", + "noMultipleSpacesInRegularExpressionLiterals": "error", + "noUselessCatch": "error", + "noUselessConstructor": "error", + "noUselessFragments": "error", + "noUselessLabel": "error", + "noUselessLoneBlockStatements": "error", + "noUselessRename": "error", + "noUselessTernary": "error", + "noVoid": "error", + "noWith": "error", + "useArrowFunction": "off", + "useLiteralKeys": "error", + "useRegexLiterals": "error" + }, + "correctness": { + "noUnusedImports": "warn", + "noChildrenProp": "error", + "noConstAssign": "error", + "noConstantCondition": "warn", + "noConstructorReturn": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "error", + "noInvalidConstructorSuper": "error", + "noInvalidUseBeforeDeclaration": "off", + "noNewSymbol": "error", + "noNodejsModules": "off", + "noNonoctalDecimalEscape": "error", + "noPrecisionLoss": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedLabels": "error", + "noUnusedPrivateClassMembers": "off", + "noUnusedVariables": "warn", + "noVoidElementsWithChildren": "error", + "useArrayLiterals": "off", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "useIsNan": "error", + "useJsxKeyInIterable": "error", + "useValidForDirection": "error", + "useYield": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "warn", + "noDangerouslySetInnerHtmlWithChildren": "error", + "noGlobalEval": "error" + }, + "style": { + "noArguments": "error", + "noCommaOperator": "error", + "noDefaultExport": "off", + "noImplicitBoolean": "error", + "noNegationElse": "off", + "noParameterAssign": "error", + "noRestrictedGlobals": { + "level": "error", + "options": { + "deniedGlobals": [ + "isFinite", + "isNaN", + "addEventListener", + "blur", + "close", + "closed", + "confirm", + "defaultStatus", + "defaultstatus", + "event", + "external", + "find", + "focus", + "frameElement", + "frames", + "history", + "innerHeight", + "innerWidth", + "length", + "location", + "locationbar", + "menubar", + "moveBy", + "moveTo", + "name", + "onblur", + "onerror", + "onfocus", + "onload", + "onresize", + "onunload", + "open", + "opener", + "opera", + "outerHeight", + "outerWidth", + "pageXOffset", + "pageYOffset", + "parent", + "print", + "removeEventListener", + "resizeBy", + "resizeTo", + "screen", + "screenLeft", + "screenTop", + "screenX", + "screenY", + "scroll", + "scrollbars", + "scrollBy", + "scrollTo", + "scrollX", + "scrollY", + "self", + "status", + "statusbar", + "stop", + "toolbar", + "top" + ] + } + }, + "noUselessElse": "error", + "noVar": "error", + "useBlockStatements": "warn", + "useCollapsedElseIf": "error", + "useConst": "error", + "useDefaultParameterLast": "error", + "useExponentiationOperator": "error", + "useFragmentSyntax": "error", + "useImportType": "warn", + "useNamingConvention": { + "level": "error", + "options": { + "strictCase": false, + "conventions": [ + { + "selector": { "kind": "function" }, + "formats": ["camelCase", "PascalCase"] + }, + { + "selector": { "kind": "variable" }, + "formats": ["camelCase", "PascalCase", "CONSTANT_CASE"] + }, + { "selector": { "kind": "typeLike" }, "formats": ["PascalCase"] }, + { + "selector": { "kind": "objectLiteralMember" }, + "formats": ["camelCase", "snake_case"] + }, + { + "selector": { "kind": "objectLiteralProperty" }, + "formats": ["camelCase", "snake_case"] + } + ] + } + }, + "useNumericLiterals": "error", + "useShorthandAssign": "error", + "useSingleVarDeclarator": "error", + "useTemplate": "error" + }, + "suspicious": { + "noArrayIndexKey": "error", + "noAssignInExpressions": "error", + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCommentText": "error", + "noCompareNegZero": "error", + "noConfusingLabels": "error", + "noConsoleLog": "warn", + "noControlCharactersInRegex": "error", + "noDebugger": "error", + "noDoubleEquals": "error", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateJsxProps": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "error", + "noExplicitAny": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "error", + "noImportAssign": "error", + "noLabelVar": "error", + "noMisleadingCharacterClass": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noSelfCompare": "error", + "noShadowRestrictedNames": "error", + "noUnsafeNegation": "error", + "useAwait": "off", + "useDefaultSwitchClauseLast": "error", + "useGetterReturn": "error", + "useValidTypeof": "error" + } + } + }, + "overrides": [ + { + "include": ["*.ts", "*.tsx"], + "linter": { + "rules": { + "correctness": { + "noConstAssign": "off", + "noGlobalObjectCalls": "off", + "noInvalidConstructorSuper": "off", + "noNewSymbol": "off", + "noSetterReturn": "off", + "noUndeclaredVariables": "off", + "noUnreachable": "off", + "noUnreachableSuper": "off" + }, + "suspicious": { + "noDuplicateClassMembers": "off", + "noDuplicateObjectKeys": "off", + "noDuplicateParameters": "off", + "noFunctionAssign": "off", + "noImportAssign": "off", + "noRedeclare": "off", + "noUnsafeNegation": "off", + "useGetterReturn": "off", + "useValidTypeof": "off" + } + } + } + } + ], + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "asNeeded", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto" + } + } }