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
3 changes: 3 additions & 0 deletions .github/workflows/backend-docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ jobs:
echo "Pulling backend image tag: $IMAGE_TAG"
IMAGE_TAG="$IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" pull bitfinance-api

echo "Applying database migrations with image tag: $IMAGE_TAG"
IMAGE_TAG="$IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" run --rm --no-deps bitfinance-api --migrate

echo "Starting backend service with image tag: $IMAGE_TAG"
IMAGE_TAG="$IMAGE_TAG" docker compose "${COMPOSE_FILES[@]}" up -d --no-build bitfinance-api

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/main-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ jobs:
- name: Build backend
run: dotnet build apps/backend/BitFinance.sln --disable-build-servers -v:minimal

- name: Test backend
run: dotnet test apps/backend/BitFinance.sln --no-build --disable-build-servers -v:minimal

mcp:
runs-on: ubuntu-latest
needs: changes
Expand Down
7 changes: 7 additions & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ JWT_EXPIRATION=2880

# Cache Settings
CACHE_ENABLED=false

# Notifications (in-app is always enabled; email is opt-in locally)
NOTIFICATIONS_EMAIL_ENABLED=false
RESEND_API_TOKEN=
RESEND_WEBHOOK_SECRET=
NOTIFICATIONS_FROM_ADDRESS=BitFinance <notifications@example.com>
NOTIFICATIONS_FRONTEND_BASE_URL=http://localhost:5174
7 changes: 7 additions & 0 deletions apps/backend/.env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ CACHE_ENABLED=true
# CORS Configuration
CORS_ALLOWED_ORIGIN_0=

# Notifications (Resend)
NOTIFICATIONS_EMAIL_ENABLED=true
RESEND_API_TOKEN=
RESEND_WEBHOOK_SECRET=
NOTIFICATIONS_FROM_ADDRESS=
NOTIFICATIONS_FRONTEND_BASE_URL=

# MCP Server Configuration
MCP_API_BASE_URL=http://bitfinance-api:8080
MCP_AGENT_EMAIL=
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/BitFinance.sln
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,8 @@ Global
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{186A8463-54A5-427F-AEC9-7DB9711EAD04} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{1E020C26-2D18-4BD1-BFA8-F6EA43474315} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{2CF72540-C71A-4C72-9BC8-F46C9DF7CDC7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{BFD7E9AA-30E6-41E6-B473-6B9E1159798F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
15 changes: 15 additions & 0 deletions apps/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ BitFinance is a finance platform for tracking bills, expenses, organizations, an
- HTTP-only refresh token cookies
- Multi-device session management
- Subscription plans with plan-based entitlements
- Durable in-app notifications and paid-plan bill reminder emails through Resend
- PostgreSQL persistence through Entity Framework Core
- Optional Redis caching
- Local object storage with MinIO and production S3-compatible storage
Expand Down Expand Up @@ -115,6 +116,11 @@ The local `.env.example` includes safe development defaults.
| `JWT_AUDIENCE` | JWT audience | `bitfinance-dev` |
| `JWT_EXPIRATION` | Access token lifetime in minutes | `2880` |
| `CACHE_ENABLED` | Enables Redis-backed caching | `false` |
| `NOTIFICATIONS_EMAIL_ENABLED` | Enables Resend delivery for eligible plans | `false` |
| `RESEND_API_TOKEN` | Resend API token | Empty |
| `RESEND_WEBHOOK_SECRET` | Resend/Svix webhook signing secret | Empty |
| `NOTIFICATIONS_FROM_ADDRESS` | Verified sender address | Development placeholder |
| `NOTIFICATIONS_FRONTEND_BASE_URL` | Base URL used by notification email links | `http://localhost:5174` |

Production deployments should use `.env.prod.example` as a template and provide real values for database credentials, JWT settings, Azure Key Vault configuration, and image tags.

Expand All @@ -140,6 +146,9 @@ Primary route groups:
- `/api/v1/organizations/{organizationId}/expenses`
- `/api/v1/organizations/{organizationId}/dashboard/upcoming-bills`
- `/api/v1/organizations/{organizationId}/dashboard/recent-expenses`
- `/api/v1/organizations/{organizationId}/notifications`
- `/api/v1/organizations/{organizationId}/notification-preferences`
- `POST /api/v1/webhooks/resend`

Use Scalar at `http://localhost:8080/scalar/v1` for the full interactive API reference.

Expand All @@ -157,6 +166,12 @@ Apply migrations with:
dotnet ef database update --project apps/backend/src/BitFinance.Data --startup-project apps/backend/src/BitFinance.API
```

Container deployments can run the migration-only command before replacing the API:

```bash
docker compose run --rm --no-deps bitfinance-api --migrate
```

In development, the API also applies migrations automatically during startup.

## Docker
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ services:
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID}
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET}
- AZURE_TENANT_ID=${AZURE_TENANT_ID}
- Notifications__EmailEnabled=${NOTIFICATIONS_EMAIL_ENABLED}
- Notifications__ResendApiToken=${RESEND_API_TOKEN}
- Notifications__ResendWebhookSecret=${RESEND_WEBHOOK_SECRET}
- Notifications__FromAddress=${NOTIFICATIONS_FROM_ADDRESS}
- Notifications__FrontendBaseUrl=${NOTIFICATIONS_FRONTEND_BASE_URL}
deploy:
resources:
limits:
Expand Down
9 changes: 8 additions & 1 deletion apps/backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ services:
- ASPNETCORE_URLS=http://+:8080
- ConnectionStrings__Database=Host=bitfinance-db;Port=5432;Database=${DB_NAME:-bitfinance};Username=${DB_USER:-postgres};Password=${DB_PASSWORD:-postgres}
- ConnectionStrings__Cache=${REDIS_CONNECTION_STRING:-bitfinance-cache:6379}
- AppSettings__CacheEnabled=${CACHE_ENABLED:-true}
- AppSettings__CacheEnabled=${CACHE_ENABLED:-false}
- AppSettings__LoggingEnabled=true
- Logging__LogLevel__Default=Warning
- Logging__LogLevel__Microsoft.AspNetCore=Warning
Expand All @@ -26,6 +26,11 @@ services:
- Jwt__ExpirationInMinutes=${JWT_EXPIRATION:-60}
- Storage__BucketName=${S3_BUCKET_NAME:-bitfinance}
- Storage__Region=${S3_REGION:-us-east-1}
- Notifications__EmailEnabled=${NOTIFICATIONS_EMAIL_ENABLED:-false}
- Notifications__ResendApiToken=${RESEND_API_TOKEN:-}
- Notifications__ResendWebhookSecret=${RESEND_WEBHOOK_SECRET:-}
- Notifications__FromAddress=${NOTIFICATIONS_FROM_ADDRESS:-}
- Notifications__FrontendBaseUrl=${NOTIFICATIONS_FRONTEND_BASE_URL:-http://localhost:5174}
restart: unless-stopped
networks:
- bitfinance-network
Expand Down Expand Up @@ -56,6 +61,8 @@ services:
bitfinance-cache:
image: redis:7-alpine
container_name: bitfinance-cache
profiles:
- cache
networks:
- bitfinance-network
restart: unless-stopped
Expand Down
163 changes: 163 additions & 0 deletions apps/backend/scripts/migrations/V014__add_notifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
START TRANSACTION;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE TABLE notification_outbox_messages (
id uuid NOT NULL,
organization_id uuid NOT NULL,
type character varying(64) NOT NULL,
aggregate_id character varying(128),
deduplication_key character varying(256) NOT NULL,
payload_json jsonb NOT NULL,
created_at timestamp(3) with time zone NOT NULL,
processed_at timestamp(3) with time zone,
attempts integer NOT NULL,
next_attempt_at timestamp(3) with time zone NOT NULL,
locked_until timestamp(3) with time zone,
last_error character varying(2000),
CONSTRAINT pk_notification_outbox_messages PRIMARY KEY (id)
);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE TABLE notification_preferences (
user_id text NOT NULL,
organization_id uuid NOT NULL,
email_bill_reminders_enabled boolean NOT NULL DEFAULT TRUE,
created_at timestamp(3) with time zone NOT NULL,
updated_at timestamp(3) with time zone,
CONSTRAINT "PK_notification_preferences" PRIMARY KEY (user_id, organization_id),
CONSTRAINT fk_notification_preferences_asp_net_users_user_id FOREIGN KEY (user_id) REFERENCES asp_net_users (id) ON DELETE CASCADE,
CONSTRAINT fk_notification_preferences_organizations_organization_id FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE
);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE TABLE notifications (
id uuid NOT NULL,
source_event_id uuid NOT NULL,
organization_id uuid NOT NULL,
recipient_user_id text NOT NULL,
type character varying(64) NOT NULL,
payload_json jsonb NOT NULL,
action_path character varying(500) NOT NULL,
created_at timestamp(3) with time zone NOT NULL,
read_at timestamp(3) with time zone,
CONSTRAINT pk_notifications PRIMARY KEY (id),
CONSTRAINT fk_notifications_asp_net_users_recipient_user_id FOREIGN KEY (recipient_user_id) REFERENCES asp_net_users (id) ON DELETE CASCADE,
CONSTRAINT fk_notifications_organizations_organization_id FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE
);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE TABLE provider_webhook_receipts (
provider_event_id character varying(256) NOT NULL,
received_at timestamp(3) with time zone NOT NULL,
CONSTRAINT "PK_provider_webhook_receipts" PRIMARY KEY (provider_event_id)
);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE TABLE notification_deliveries (
id uuid NOT NULL,
notification_id uuid NOT NULL,
channel character varying(32) NOT NULL,
status character varying(32) NOT NULL,
attempts integer NOT NULL,
next_attempt_at timestamp(3) with time zone NOT NULL,
locked_until timestamp(3) with time zone,
provider_message_id character varying(256),
provider_event_at timestamp(3) with time zone,
sent_at timestamp(3) with time zone,
last_error character varying(2000),
CONSTRAINT pk_notification_deliveries PRIMARY KEY (id),
CONSTRAINT fk_notification_deliveries_notifications_notification_id FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE
);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE UNIQUE INDEX "IX_notification_deliveries_notification_id_channel" ON notification_deliveries (notification_id, channel);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE INDEX "IX_notification_deliveries_provider_message_id" ON notification_deliveries (provider_message_id);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE INDEX "IX_notification_deliveries_status_next_attempt_at_locked_until" ON notification_deliveries (status, next_attempt_at, locked_until);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE UNIQUE INDEX "IX_notification_outbox_messages_deduplication_key" ON notification_outbox_messages (deduplication_key);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE INDEX "IX_notification_outbox_messages_processed_at_next_attempt_at_l~" ON notification_outbox_messages (processed_at, next_attempt_at, locked_until);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE INDEX ix_notification_preferences_organization_id ON notification_preferences (organization_id);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE INDEX "IX_notifications_organization_id_recipient_user_id_read_at_cre~" ON notifications (organization_id, recipient_user_id, read_at, created_at);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE INDEX ix_notifications_recipient_user_id ON notifications (recipient_user_id);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
CREATE UNIQUE INDEX "IX_notifications_source_event_id_recipient_user_id" ON notifications (source_event_id, recipient_user_id);
END IF;
END $EF$;

DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260715040314_AddNotifications') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260715040314_AddNotifications', '10.0.0');
END IF;
END $EF$;
COMMIT;

1 change: 1 addition & 0 deletions apps/backend/src/BitFinance.API/BitFinance.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<PackageReference Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.MSSqlServer" Version="7.0.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.0.18" />
<PackageReference Include="Svix" Version="1.96.1" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Security.Claims;
using Asp.Versioning;
using BitFinance.API.Attributes;
using BitFinance.API.Models;
using BitFinance.API.Models.Request;
using BitFinance.API.Models.Response;
using BitFinance.API.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace BitFinance.API.Controllers;

[ApiController]
[Authorize]
[OrganizationAuthorization]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/organizations/{organizationId:guid}")]
public sealed class NotificationsController(INotificationService notificationService) : ControllerBase
{
[HttpGet("notifications")]
public async Task<ActionResult<PagedResponse<NotificationResponse>>> GetNotifications(
Guid organizationId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool unreadOnly = false,
CancellationToken cancellationToken = default)
{
var userId = GetUserId();
if (userId is null) return Unauthorized();
return Ok(await notificationService.GetAsync(
organizationId, userId, page, pageSize, unreadOnly, cancellationToken));
}

[HttpGet("notifications/unread-count")]
public async Task<ActionResult<NotificationUnreadCountResponse>> GetUnreadCount(
Guid organizationId,
CancellationToken cancellationToken)
{
var userId = GetUserId();
if (userId is null) return Unauthorized();
var count = await notificationService.GetUnreadCountAsync(organizationId, userId, cancellationToken);
return Ok(new NotificationUnreadCountResponse(count));
}

[HttpPatch("notifications/{notificationId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid organizationId, Guid notificationId, CancellationToken cancellationToken)
{
var userId = GetUserId();
if (userId is null) return Unauthorized();
return await notificationService.MarkReadAsync(
organizationId, userId, notificationId, cancellationToken) ? NoContent() : NotFound();
}

[HttpPost("notifications/read-all")]
public async Task<IActionResult> MarkAllRead(Guid organizationId, CancellationToken cancellationToken)
{
var userId = GetUserId();
if (userId is null) return Unauthorized();
await notificationService.MarkAllReadAsync(organizationId, userId, cancellationToken);
return NoContent();
}

[HttpGet("notification-preferences")]
public async Task<ActionResult<NotificationPreferenceResponse>> GetPreferences(
Guid organizationId,
CancellationToken cancellationToken)
{
var userId = GetUserId();
if (userId is null) return Unauthorized();
return Ok(await notificationService.GetPreferencesAsync(organizationId, userId, cancellationToken));
}

[HttpPut("notification-preferences")]
public async Task<ActionResult<NotificationPreferenceResponse>> UpdatePreferences(
Guid organizationId,
[FromBody] UpdateNotificationPreferenceRequest request,
CancellationToken cancellationToken)
{
var userId = GetUserId();
if (userId is null) return Unauthorized();
return Ok(await notificationService.UpdatePreferencesAsync(
organizationId, userId, request.EmailBillRemindersEnabled, cancellationToken));
}

private string? GetUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier);
}
Loading
Loading