Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 126 additions & 5 deletions apps/api/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const GetMeEndpoint = api.auth.me
.inject({ db: DbToken })
.summary('Current user')
.description(
'Returns preferences and transaction currency ordering for the authenticated user.'
'Returns preferences, accessible budgets, and derived transaction currency ordering for the authenticated user.'
)
.tags('users')
.operationId('getCurrentUser');
Expand All @@ -91,7 +91,7 @@ export const UpdatePreferencesEndpoint = api.users.updatePreferences
.inject({ db: DbToken })
.summary('Update preferences')
.description(
'Updates the current user default currency, favorite currencies, and timezone.'
'Updates the current user country, timezone, and email report preferences.'
)
.tags('users')
.operationId('updateUserPreferences');
Expand Down Expand Up @@ -122,6 +122,112 @@ export const DisconnectTelegramEndpoint = api.users.disconnectTelegram
.tags('users')
.operationId('disconnectTelegram');

export const UpdateUserAvatarEndpoint = api.users.updateAvatar
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Update user avatar')
.description('Stores a manually uploaded avatar for the current user.')
.tags('users')
.operationId('updateUserAvatar');

export const DeleteUserAvatarEndpoint = api.users.deleteAvatar
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Delete user avatar')
.description('Removes the current user manually uploaded avatar.')
.tags('users')
.operationId('deleteUserAvatar');

export const UserAvatarImageEndpoint = api.users.avatarImage
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('User avatar image')
.description('Streams a stored avatar image visible to the current user.')
.tags('users')
.operationId('userAvatarImage');

export const ListBudgetsEndpoint = api.budgets.list
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('List budgets')
.description('Lists budgets accessible to the authenticated user.')
.tags('budgets')
.operationId('listBudgets');

export const CreateBudgetEndpoint = api.budgets.create
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Create budget')
.description('Creates a new budget with the current user as admin.')
.tags('budgets')
.operationId('createBudget');

export const UpdateBudgetEndpoint = api.budgets.update
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Update budget')
.description('Updates budget name, defaults, or archive state.')
.tags('budgets')
.operationId('updateBudget');

export const DeleteBudgetEndpoint = api.budgets.delete
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Delete budget')
.description('Permanently deletes an archived non-main budget.')
.tags('budgets')
.operationId('deleteBudget');

export const ListBudgetMembersEndpoint = api.budgets.members
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('List budget members')
.description('Lists members for a budget the current user can manage.')
.tags('budgets')
.operationId('listBudgetMembers');

export const ListBudgetAccessEndpoint = api.budgets.access
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('List budget access')
.description(
'Lists active members and invitation statuses for a budget the current user can manage.'
)
.tags('budgets')
.operationId('listBudgetAccess');

export const InviteBudgetMemberEndpoint = api.budgets.invite
.authorize(PrincipalSchema)
.inject({ db: DbToken, config: ConfigToken })
.summary('Invite budget member')
.description('Sends a magic link invitation for an existing user.')
.tags('budgets')
.operationId('inviteBudgetMember');

export const UpdateBudgetMemberEndpoint = api.budgets.updateMember
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Update budget member')
.description('Updates budget member role and permissions.')
.tags('budgets')
.operationId('updateBudgetMember');

export const RemoveBudgetMemberEndpoint = api.budgets.removeMember
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Remove budget member')
.description('Removes a user from a shared budget.')
.tags('budgets')
.operationId('removeBudgetMember');

export const AcceptBudgetInvitationEndpoint = api.budgets.acceptInvitation
.authorize(PrincipalSchema)
.inject({ db: DbToken })
.summary('Accept budget invitation')
.description('Consumes a budget invitation magic link.')
.tags('budgets')
.operationId('acceptBudgetInvitation');

export const ListApiKeysEndpoint = api.users.listApiKeys
.authorize(PrincipalSchema)
.inject({ db: DbToken })
Expand Down Expand Up @@ -214,7 +320,7 @@ export const ConvertCurrencyEndpoint = api.currencies.convert
.inject({ db: DbToken, config: ConfigToken })
.summary('Convert currency')
.description(
'Converts an entered amount to the authenticated user default currency.'
'Converts an entered amount to the selected budget default currency.'
)
.tags('currencies')
.operationId('convertCurrency');
Expand Down Expand Up @@ -357,7 +463,7 @@ export const UpdateTransactionEndpoint = api.transactions.update

export const ListTransactionTagsEndpoint = api.transactionTags.list
.authorize(PrincipalSchema)
.inject({ knex: KnexToken })
.inject({ db: DbToken })
.summary('List transaction tags')
.description('Lists transaction tags owned by the authenticated user.')
.tags('transaction-tags')
Expand All @@ -373,7 +479,7 @@ export const DeleteTransactionEndpoint = api.transactions.delete

export const GetTransactionScanImageEndpoint = api.transactions.scanImage
.authorize(PrincipalSchema)
.inject({ knex: KnexToken })
.inject({ db: DbToken, knex: KnexToken })
.summary('Get scanned transaction image')
.description(
'Returns the original scanner image attached to a confirmed transaction.'
Expand Down Expand Up @@ -493,12 +599,27 @@ export const endpoints = {
telegramStatus: TelegramStatusEndpoint,
createTelegramLinkToken: CreateTelegramLinkTokenEndpoint,
disconnectTelegram: DisconnectTelegramEndpoint,
updateAvatar: UpdateUserAvatarEndpoint,
deleteAvatar: DeleteUserAvatarEndpoint,
avatarImage: UserAvatarImageEndpoint,
listApiKeys: ListApiKeysEndpoint,
createApiKey: CreateApiKeyEndpoint,
revokeApiKey: RevokeApiKeyEndpoint,
listMcpOAuthConnections: ListMcpOAuthConnectionsEndpoint,
revokeMcpOAuthConnection: RevokeMcpOAuthConnectionEndpoint
},
budgets: {
list: ListBudgetsEndpoint,
create: CreateBudgetEndpoint,
update: UpdateBudgetEndpoint,
delete: DeleteBudgetEndpoint,
members: ListBudgetMembersEndpoint,
access: ListBudgetAccessEndpoint,
invite: InviteBudgetMemberEndpoint,
updateMember: UpdateBudgetMemberEndpoint,
removeMember: RemoveBudgetMemberEndpoint,
acceptInvitation: AcceptBudgetInvitationEndpoint
},
oauth: {
authorizationRequest: McpOAuthAuthorizationRequestEndpoint,
authorize: McpOAuthAuthorizeEndpoint
Expand Down
64 changes: 60 additions & 4 deletions apps/api/src/api/handlers/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,55 @@ const singleUserConfig = {
}
} as Config;

function budgetAccessTables(userId = 12) {
const timestamp = new Date('2026-06-01T00:00:00.000Z');
const budget = {
id: 1,
name: 'Main',
defaultCurrency: 'USD',
countryCode: 'US',
createdByUserId: userId,
createdAt: timestamp,
updatedAt: timestamp
};
const member = {
budgetId: 1,
userId,
role: 'admin',
canCreateTransactions: true,
canUpdateTransactions: true,
canDeleteTransactions: true,
canManageCategories: true,
canManageVendors: true,
canManageTags: true,
canManageMembers: true,
createdAt: timestamp,
updatedAt: timestamp
};

return {
budgets: {
find: vi.fn(async () => budget),
insert: vi.fn(async () => budget)
},
budgetMembers: {
insert: vi.fn(async () => member),
where: vi.fn(() => ({
where: vi.fn(() => ({
first: vi.fn(async () => member)
}))
}))
}
};
}

function mockDb(user: object | undefined): AppDb {
return {
...budgetAccessTables(),
users: {
find: vi.fn(async () => user)
find: vi.fn(async () =>
user ? { mainBudgetId: 1, ...user } : user
)
},
categories: {
where: vi.fn(() => ({
Expand Down Expand Up @@ -65,15 +110,26 @@ function singleUserDb(existing?: object): AppDb {
};
return stored;
}),
find: vi.fn(async (id: number) => (stored?.id === id ? stored : null))
find: vi.fn(async (id: number) => (stored?.id === id ? stored : null)),
where: vi.fn(() => ({
update: vi.fn(async values => {
stored = stored ? { ...stored, ...values } : stored;
})
}))
};
const budgetTables = budgetAccessTables();

return {
transaction: vi.fn(
async (
callback: (trx: { readonly users: typeof users }) => unknown
) => callback({ users })
callback: (trx: {
readonly budgetMembers: typeof budgetTables.budgetMembers;
readonly budgets: typeof budgetTables.budgets;
readonly users: typeof users;
}) => unknown
) => callback({ ...budgetTables, users })
),
...budgetTables,
users,
categories: {
where: vi.fn(() => ({
Expand Down
70 changes: 67 additions & 3 deletions apps/api/src/api/handlers/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ActionResult, type Handler } from '@cleverbrush/server';
import {
deleteUserAvatar,
getUserAvatarImage,
UserAvatarError,
updateUserAvatar
} from '../../application/user-avatars.js';
import {
confirmEmail,
DuplicateEmailError,
Expand Down Expand Up @@ -29,6 +35,7 @@ import {
} from '../../security/passport.js';
import type {
ConfirmEmailEndpoint,
DeleteUserAvatarEndpoint,
GetMeEndpoint,
GoogleSignInEndpoint,
LoginEndpoint,
Expand All @@ -38,7 +45,9 @@ import type {
ResendEmailConfirmationEndpoint,
SessionTokenEndpoint,
SingleUserSessionTokenEndpoint,
UpdatePreferencesEndpoint
UpdatePreferencesEndpoint,
UpdateUserAvatarEndpoint,
UserAvatarImageEndpoint
} from '../endpoints.js';

const webServiceSecretHeader = 'x-xpenser-web-secret';
Expand Down Expand Up @@ -292,8 +301,6 @@ export const updatePreferencesHandler: Handler<
const preference = await updateUserPreference(
db,
principal.userId,
body.defaultCurrency,
body.favoriteCurrencies,
body.countryCode,
body.timezone,
body.weeklyEmailReportEnabled,
Expand All @@ -304,3 +311,60 @@ export const updatePreferencesHandler: Handler<
}
return preference;
};

export const updateUserAvatarHandler: Handler<
typeof UpdateUserAvatarEndpoint
> = async ({ body, principal }, { db }) => {
try {
const preference = await updateUserAvatar(db, principal.userId, body);
if (!preference) {
return ActionResult.unauthorized({
message: 'User was not found.'
});
}
return preference;
} catch (err) {
if (err instanceof UserAvatarError) {
return ActionResult.badRequest({ message: err.message });
}
throw err;
}
};

export const deleteUserAvatarHandler: Handler<
typeof DeleteUserAvatarEndpoint
> = async ({ principal }, { db }) => {
const preference = await deleteUserAvatar(db, principal.userId);
if (!preference) {
return ActionResult.unauthorized({ message: 'User was not found.' });
}
return preference;
};

export const userAvatarImageHandler: Handler<
typeof UserAvatarImageEndpoint
> = async ({ params, principal }, { db }) => {
const image = await getUserAvatarImage(db, principal.userId, params.id);
if (!image) {
return ActionResult.notFound({
message: 'Avatar image was not found.'
});
}

return ActionResult.raw(async (_request, response) => {
const buffer = Buffer.from(image.imageBase64, 'base64');
response.setHeader('Content-Type', image.mimeType);
response.setHeader('Content-Length', String(buffer.byteLength));
response.setHeader('Cache-Control', 'private, max-age=300');
if (image.fileName) {
response.setHeader(
'Content-Disposition',
`inline; filename="${image.fileName.replaceAll('"', '')}"`
);
}
if (image.updatedAt) {
response.setHeader('Last-Modified', image.updatedAt.toUTCString());
}
response.end(buffer);
});
};
Loading
Loading