Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
085e192
chore: fix truncated HBS templates, env config for admin seeding, and…
JohnUghiovhe Jun 8, 2026
15c1bad
feat(admin): add admin notification preferences endpoints and wiring
JohnUghiovhe Jun 8, 2026
5328f01
chore:update SEED_ADMIN_EMAIL type to allow empty string; improve for…
JohnUghiovhe Jun 8, 2026
5f13ab5
fix(funnels): allocate LLM context budget per document instead of sli…
nsien-prestige Jun 8, 2026
8c0d2a4
test(funnels): cover multi-document context budget distribution (UPL-…
nsien-prestige Jun 8, 2026
cb57596
feat(email): add dashboard CTA links to funnel-ready, weekly-digest a…
Jun 8, 2026
0a2d5a0
Merge pull request #226 from hngprojects/chore/fix-hbs-templates-env-…
akinwalexander Jun 8, 2026
588f82e
fix(funnels): adjust document context budget calculation for funnel g…
nsien-prestige Jun 8, 2026
fcc3520
fix(funnels): enforce 4000-char budget limit for funnel generation co…
nsien-prestige Jun 8, 2026
98f39d8
Merge remote-tracking branch 'origin/dev' into feat/email-cta-links
Jun 8, 2026
4c551a0
fix(funnels): add tests for handling funnel failures and ensure no PI…
nsien-prestige Jun 8, 2026
7d88a85
feat(audio): add config files
elijaharhinful Jun 8, 2026
ae22223
feat(audio): add constants
elijaharhinful Jun 8, 2026
57d8474
feat(upload): add source to entity
elijaharhinful Jun 8, 2026
cef3b0c
Merge branch 'dev' of github.com:hngprojects/flowbrand-api into fix/1…
nsien-prestige Jun 8, 2026
b712018
docs(audio): add swagger docs
elijaharhinful Jun 8, 2026
0fff92a
Merge branch 'dev' of github.com:hngprojects/flowbrand-api into feat/…
elijaharhinful Jun 8, 2026
ff31646
Merge pull request #234 from hngprojects/feat/email-cta-links
akinwalexander Jun 8, 2026
b2578a9
feat(audio): add base files
elijaharhinful Jun 8, 2026
9ebdd3e
Merge branch 'dev' of github.com:hngprojects/flowbrand-api into fix/1…
nsien-prestige Jun 8, 2026
429542c
Merge pull request #235 from hngprojects/fix/155-funnel-generation-bugs
akinwalexander Jun 8, 2026
7f10aea
fix(audio): resolve errors in voice services and controllers
elijaharhinful Jun 8, 2026
a0e3b16
docs(audio): add swagger documentation for complete endpoint
elijaharhinful Jun 8, 2026
e3b2ea2
feat(audio): add status endpoint for voice upload
elijaharhinful Jun 8, 2026
acace8d
Merge branch 'dev' of github.com:hngprojects/flowbrand-api into feat/…
elijaharhinful Jun 8, 2026
14cbca1
feat(audio): add migration migration file
elijaharhinful Jun 8, 2026
975a3e9
test(audio): add unit tests for voice service
elijaharhinful Jun 8, 2026
0aacea3
fix(audio): resolve failing lint tests
elijaharhinful Jun 8, 2026
9037226
fix(audio): resolve rabbit comments
elijaharhinful Jun 8, 2026
928f633
fix(audio): resolve document type mismatch
elijaharhinful Jun 8, 2026
0fb57c8
feat(audio): add active session retrieval endpoint
elijaharhinful Jun 8, 2026
4cd190a
fix(audio): fix code rabbit comments
elijaharhinful Jun 8, 2026
2a6aa08
feat(llm): generate longer, more detailed funnel task descriptions
Jun 8, 2026
4b08fe5
fix(auth): add message for conflict when Google email is linked to a …
nsien-prestige Jun 9, 2026
c58375f
tests(auth): handle ConflictException for local account linking with …
nsien-prestige Jun 9, 2026
d90672b
fix(auth): update system messages for clarity and consistency
nsien-prestige Jun 9, 2026
448d425
fix(auth): improve code formatting and enhance conflict handling for …
nsien-prestige Jun 9, 2026
ed8a7b8
tests(auth): enhance user account handling in OAuth login flow and im…
nsien-prestige Jun 9, 2026
e082839
fix(auth): enhance user retrieval by including soft-deleted accounts …
nsien-prestige Jun 9, 2026
f9108d5
tests(auth): update user creation logic to handle soft-deleted accoun…
nsien-prestige Jun 9, 2026
26ff45e
tests(auth): update test case identifiers for soft-deleted account ha…
nsien-prestige Jun 9, 2026
fbad226
chore(audio): use system messagese in tes in ts in test file
elijaharhinful Jun 9, 2026
973972f
Merge pull request #236 from hngprojects/feat/audio-service
akinwalexander Jun 9, 2026
a4380e5
Merge branch 'dev' into feat/detailed-funnel-task-descriptions
akinwalexander Jun 9, 2026
548a075
Merge pull request #237 from hngprojects/feat/detailed-funnel-task-de…
akinwalexander Jun 9, 2026
ca265cf
Merge remote-tracking branch 'origin/dev' into fix/153-auth-identity-…
nsien-prestige Jun 9, 2026
4b4f77f
refactor: update code sections to tighten validations and easy readab…
JohnUghiovhe Jun 9, 2026
5cc07aa
Merge branch 'dev' into feat/BE-ADM-612-admin-notification-preferences
JohnUghiovhe Jun 9, 2026
cdda6b1
Merge pull request #238 from hngprojects/fix/153-auth-identity-bugs
akinwalexander Jun 9, 2026
56531f0
Merge branch 'dev' into feat/BE-ADM-612-admin-notification-preferences
akinwalexander Jun 9, 2026
a73919a
Merge pull request #240 from hngprojects/feat/BE-ADM-612-admin-notifi…
akinwalexander Jun 9, 2026
bca113f
feat(admin/logs): capture request user agent on log entries
ibraheembello Jun 10, 2026
bc3c4f4
feat(admin/logs): expose location and device on admin logs feed
ibraheembello Jun 10, 2026
7dfa74e
Merge pull request #242 from hngprojects/feat/admin-logs-expose-locat…
akinwalexander Jun 10, 2026
a6b8aed
Merge pull request #241 from hngprojects/feat/admin-logs-capture-user…
akinwalexander Jun 10, 2026
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ GEMINI_TIMEOUT_MS=60000
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile
GROQ_TIMEOUT_MS=60000
GROQ_WHISPER_MODEL=whisper-large-v3-turbo

ASSEMBLYAI_API_KEY=

CONTACT_ADMIN_EMAIL=useseil@hng14.com

Expand Down
1 change: 1 addition & 0 deletions src/common/constants/queue.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const QUEUES = {
FUNNEL_GENERATION: 'funnel-generation',
EMAIL: 'email',
DOCUMENT_EXTRACTION: 'document-extraction',
VOICE_TRANSCRIPTION: 'voice-transcription',
} as const;

export const JOBS = {
Expand Down
16 changes: 16 additions & 0 deletions src/common/services/log.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const MAX_STRING_LENGTH = 500;
const MAX_SCRUB_DEPTH = 8;
/** ip_address column is varchar(45) — a full IPv6 textual address. */
const MAX_IP_LENGTH = 45;
/** user_agent column is varchar(512); truncate anything longer before persisting. */
const MAX_USER_AGENT_LENGTH = 512;

/**
* Shared audit-trail writer (BE-ADM-609). Persists admin_logs rows for the
Expand Down Expand Up @@ -44,6 +46,7 @@ export class LogService {
// Capture request-derived data synchronously: the request object may be
// recycled by the framework before the deferred insert runs.
const ipAddress = this.extractIpAddress(req);
const userAgent = this.extractUserAgent(req);
const scrubbedMetadata = metadata ? this.scrubObject(metadata, 0) : {};

setImmediate(() => {
Expand All @@ -55,6 +58,7 @@ export class LogService {
action_type: actionType,
description,
ip_address: ipAddress,
user_agent: userAgent,
status,
metadata: scrubbedMetadata,
});
Expand Down Expand Up @@ -84,6 +88,18 @@ export class LogService {
return clientIp ? clientIp.slice(0, MAX_IP_LENGTH) : null;
}

/** Captures the raw User-Agent header; the read side parses it into a device label. */
private extractUserAgent(req: Request | null): string | null {
if (!req) {
return null;
}

// The 'user-agent' header is a single-value header (string | undefined).
const userAgent = req.headers?.['user-agent'];

return userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null;
}

/** FR-4: redact sensitive keys and truncate oversized strings, recursively. */
private scrubObject(value: Record<string, unknown>, depth: number): Record<string, unknown> {
const scrubbed: Record<string, unknown> = {};
Expand Down
25 changes: 25 additions & 0 deletions src/common/services/tests/log.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,36 @@ describe('LogService', () => {
action_type: AdminLogActionType.LOGIN,
description: 'User logged in',
ip_address: '127.0.0.1',
user_agent: null,
status: AdminLogStatus.SUCCESS,
metadata: {},
});
});

it('captures the User-Agent header when present', async () => {
const req = makeRequest({
headers: { 'user-agent': 'Mozilla/5.0 (Macintosh) Chrome/134.0.0.0 Safari/537.36' },
} as Partial<Request>);

service.log('user-1', AdminLogActionType.LOGIN, 'x', req, AdminLogStatus.SUCCESS);
await flushImmediates();

expect(mockAdminLogRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
user_agent: 'Mozilla/5.0 (Macintosh) Chrome/134.0.0.0 Safari/537.36',
}),
);
});

it('stores a null user_agent when the header is absent', async () => {
service.log('user-1', AdminLogActionType.LOGIN, 'x', makeRequest(), AdminLogStatus.SUCCESS);
await flushImmediates();

expect(mockAdminLogRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ user_agent: null }),
);
});

it('AC-04 / FR-2: returns before the database write starts', () => {
service.log('user-1', AdminLogActionType.LOGIN, 'x', null, AdminLogStatus.SUCCESS);

Expand Down
6 changes: 6 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const envSchema = z.object({

CONTACT_ADMIN_EMAIL: z.string().email().default('useseil@hng14.com'),

SEED_ADMIN_EMAIL: z.union([z.literal(''), z.string().email()]).default(''),
SEED_ADMIN_PASSWORD: z.string().default(''),

UPLOAD_STORAGE_ENDPOINT: z.string().default(''),
UPLOAD_STORAGE_ACCESS_KEY: z.string().default(''),
UPLOAD_STORAGE_SECRET_KEY: z.string().default(''),
Expand All @@ -51,6 +54,9 @@ const envSchema = z.object({
GROQ_API_KEY: z.string().min(1, 'GROQ_API_KEY is required'),
GROQ_MODEL: z.string().default('llama-3.3-70b-versatile'),
GROQ_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000),
GROQ_WHISPER_MODEL: z.string().default('whisper-large-v3-turbo'),

ASSEMBLYAI_API_KEY: z.string().optional(),

QUEUE_CONCURRENCY: z.coerce
.number()
Expand Down
4 changes: 4 additions & 0 deletions src/constants/redis-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ export const redisKeys = {
adminDashboardFunnelPerformance: () => 'admin-dashboard:funnel-performance',
adminDashboardUserStages: () => 'admin-dashboard:user-stages',
adminDashboardUserRetention: () => 'admin-dashboard:user-retention',

voiceSession: (userId: string, sessionId: string) => `voice_session:${userId}:${sessionId}`,
voiceSessionMeta: (userId: string, sessionId: string) => `voice_session_meta:${userId}:${sessionId}`,
activeVoiceSession: (userId: string) => `active_voice_session:${userId}`,
};
26 changes: 23 additions & 3 deletions src/constants/system.messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const USER_UNAUTHORIZED = 'User not authorized';
// OAuth and external auth messages
export const GOOGLE_ACCOUNT_NO_EMAIL = 'Google account has no email';
export const GOOGLE_ACCOUNT_LINK_CONFLICT = 'Google account is linked to a different account';
export const GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT =
'This email is registered with a password. Please sign in with your email and password.';
export const GOOGLE_OAUTH_FAILED = 'Google OAuth authentication failed';
export const GOOGLE_OAUTH_CONFIGURATION_INVALID = 'Google OAuth configuration is missing';
export const USER_OAUTH_CREATION_FAILED = 'Failed to create user account';
Expand Down Expand Up @@ -236,6 +238,11 @@ export const PROFILE_AVATAR_DELETE_FAILED = 'Failed to remove profile avatar';
export const NOTIFICATION_PREFERENCES_RETRIEVED_SUCCESSFULLY = 'Notification preferences retrieved successfully';
export const NOTIFICATION_PREFERENCES_UPDATED_SUCCESSFULLY = 'Notification preferences updated successfully';

// Admin notification preferences
export const ADMIN_NOTIFICATION_PREFERENCES_RETRIEVED_SUCCESSFULLY = 'Admin notification preferences retrieved successfully';
export const ADMIN_NOTIFICATION_PREFERENCES_UPDATED_SUCCESSFULLY = 'Admin notification preferences updated successfully';
export const ADMIN_NOTIFICATION_PREFERENCES_UPDATE_FAILED = 'Failed to update admin notification preferences';

// Account Deletion
export const ACCOUNT_DELETED_SUCCESSFULLY = 'Your account has been deleted. You will be signed out.';
export const ACCOUNT_ALREADY_DELETED = 'Account already deleted';
Expand Down Expand Up @@ -273,8 +280,7 @@ export const ADMIN_PROFILE_EMAIL_CHANGE_FORBIDDEN = 'Email cannot be changed her
export const ADMIN_PROFILE_RESPONSE_ROLE_RESOLUTION_FAILED = 'Failed to resolve admin role for profile response';
export const ADMIN_PASSWORD_UPDATED_SUCCESSFULLY = 'Password updated successfully';
export const ADMIN_OLD_PASSWORD_INCORRECT = 'Old password is incorrect';
export const ADMIN_NEW_PASSWORD_MUST_DIFFER_FROM_OLD =
'New password must be different from your current password';
export const ADMIN_NEW_PASSWORD_MUST_DIFFER_FROM_OLD = 'New password must be different from your current password';
export const ADMIN_CONFIRM_PASSWORD_MISMATCH = 'Confirm password must match new password';
export const ADMIN_PASSWORD_POLICY_VALIDATION_FAILED =
'new_password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one symbol';
Expand Down Expand Up @@ -363,4 +369,18 @@ export const INVITE_ACCEPTED_SUCCESSFULLY = 'Invitation accepted successfully.'
export const MEMBER_REVOKE_SELF_FORBIDDEN = 'You cannot revoke your own access.';
export const MEMBER_NOT_FOUND = 'Team member not found.';
export const MEMBER_REVOKED_SUCCESSFULLY = 'Member access revoked successfully.';
export const PASSWORD_VALIDATION_FAILED = 'password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character'
export const PASSWORD_VALIDATION_FAILED = 'password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character';

// Voice Onboarding
export const VOICE_UPLOAD_ACCEPTED = 'Voice recording accepted for processing';
export const VOICE_SESSION_COMPLETED = 'Voice session completed successfully';
export const VOICE_INVALID_AUDIO_FORMAT = 'Please upload a WebM, MP3, WAV, OGG, or M4A file.';
export const VOICE_FILE_TOO_LARGE = 'Recording is too large. Please try a shorter clip.';
export const VOICE_AUDIO_TOO_LONG = 'Please keep each recording under 2 minutes.';
export const VOICE_SESSION_EXPIRED = 'Your session has expired. Please start again.';
export const VOICE_TRANSCRIPTION_EMPTY = 'We couldn\'t understand that recording. Please try again in a quieter environment.';
export const VOICE_TRANSCRIPTION_INCOMPLETE = 'Transcription incomplete';
export const VOICE_TRANSCRIPTION_UNAVAILABLE = 'Transcription is temporarily unavailable. Please try again in a moment.';
export const VOICE_STATUS_RETRIEVED = 'Status retrieved successfully';
export const VOICE_ACTIVE_SESSION_RETRIEVED = 'Active session retrieved successfully';
export const VOICE_NO_ACTIVE_SESSION = 'No active session exists';
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AdminNotificationPreferences1780737043609 implements MigrationInterface {
name = 'AdminNotificationPreferences1780737043609';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "admin_notification_preferences" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, "general_notifications" boolean NOT NULL DEFAULT true, "push_email" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_admin_notification_preferences_id" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_admin_notification_preferences_user_id" ON "admin_notification_preferences" ("user_id")`,
);
await queryRunner.query(
`ALTER TABLE "admin_notification_preferences" ADD CONSTRAINT "FK_admin_notification_preferences_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "admin_notification_preferences" DROP CONSTRAINT "FK_admin_notification_preferences_user_id"`,
);
await queryRunner.query(`DROP INDEX "public"."IDX_admin_notification_preferences_user_id"`);
await queryRunner.query(`DROP TABLE "admin_notification_preferences"`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddSourceTypeToUploadEntity1780950180322 implements MigrationInterface {
name = 'AddSourceTypeToUploadEntity1780950180322'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_team_invitations_team"`);
await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_team_invitations_invited_by"`);
await queryRunner.query(`ALTER TABLE "admin_teams" DROP CONSTRAINT "FK_admin_teams_created_by"`);
await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_team_memberships_team"`);
await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_team_memberships_user"`);
await queryRunner.query(`ALTER TABLE "admin_logs" DROP CONSTRAINT "FK_admin_logs_user_id"`);
await queryRunner.query(`DROP INDEX "public"."IDX_team_invitations_team_email_pending"`);
await queryRunner.query(`DROP INDEX "public"."IDX_team_memberships_team_user"`);
await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_created_at"`);
await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_user_id"`);
await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_action_type"`);
await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_status"`);
await queryRunner.query(`CREATE TYPE "public"."uploaded_documents_source_type_enum" AS ENUM('document', 'voice')`);
await queryRunner.query(`ALTER TABLE "uploaded_documents" ADD "source_type" "public"."uploaded_documents_source_type_enum" NOT NULL DEFAULT 'document'`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b780a282603aaa80e05def2203" ON "team_invitations" ("team_id", "email") WHERE "status" = 'pending'`);
await queryRunner.query(`CREATE INDEX "IDX_7ace7c4b3262abd89cb75ae53b" ON "admin_logs" ("user_id") `);
await queryRunner.query(`CREATE INDEX "IDX_d2b22ec3e7c92f1e670f91a305" ON "admin_logs" ("action_type") `);
await queryRunner.query(`CREATE INDEX "IDX_d51744dc54aab8e69c2e3bb662" ON "admin_logs" ("status") `);
await queryRunner.query(`CREATE INDEX "IDX_c328cf8abb6bd5fabdd090d677" ON "admin_logs" ("created_at") `);
await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "UQ_11c823f69a675c3f05d0fc31958" UNIQUE ("team_id", "user_id")`);
await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_47d9ff0726cf20571e29480a99b" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_92d21809e16a56887210bb4dbc5" FOREIGN KEY ("invited_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "admin_teams" ADD CONSTRAINT "FK_5b12c080ebe16ac4bdaae422b58" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_b917b8603c6d5c526fcdb2009de" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_c9eb2ded8e0e2f4bcb41fd0984a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "admin_logs" ADD CONSTRAINT "FK_7ace7c4b3262abd89cb75ae53b1" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "admin_logs" DROP CONSTRAINT "FK_7ace7c4b3262abd89cb75ae53b1"`);
await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_c9eb2ded8e0e2f4bcb41fd0984a"`);
await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_b917b8603c6d5c526fcdb2009de"`);
await queryRunner.query(`ALTER TABLE "admin_teams" DROP CONSTRAINT "FK_5b12c080ebe16ac4bdaae422b58"`);
await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_92d21809e16a56887210bb4dbc5"`);
await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_47d9ff0726cf20571e29480a99b"`);
await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "UQ_11c823f69a675c3f05d0fc31958"`);
await queryRunner.query(`DROP INDEX "public"."IDX_c328cf8abb6bd5fabdd090d677"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d51744dc54aab8e69c2e3bb662"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d2b22ec3e7c92f1e670f91a305"`);
await queryRunner.query(`DROP INDEX "public"."IDX_7ace7c4b3262abd89cb75ae53b"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b780a282603aaa80e05def2203"`);
await queryRunner.query(`ALTER TABLE "uploaded_documents" DROP COLUMN "source_type"`);
await queryRunner.query(`DROP TYPE "public"."uploaded_documents_source_type_enum"`);
await queryRunner.query(`CREATE INDEX "IDX_admin_logs_status" ON "admin_logs" ("status") `);
await queryRunner.query(`CREATE INDEX "IDX_admin_logs_action_type" ON "admin_logs" ("action_type") `);
await queryRunner.query(`CREATE INDEX "IDX_admin_logs_user_id" ON "admin_logs" ("user_id") `);
await queryRunner.query(`CREATE INDEX "IDX_admin_logs_created_at" ON "admin_logs" ("created_at") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_team_memberships_team_user" ON "team_memberships" ("team_id", "user_id") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_team_invitations_team_email_pending" ON "team_invitations" ("email", "team_id") WHERE (status = 'pending'::team_invitations_status_enum)`);
await queryRunner.query(`ALTER TABLE "admin_logs" ADD CONSTRAINT "FK_admin_logs_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_team_memberships_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_team_memberships_team" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "admin_teams" ADD CONSTRAINT "FK_admin_teams_created_by" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_team_invitations_invited_by" FOREIGN KEY ("invited_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_team_invitations_team" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}

}
21 changes: 21 additions & 0 deletions src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Adds the raw User-Agent capture column to admin_logs. The value is parsed into
* a "Browser Major · OS Version" device label on read; storing it raw lets the
* parser improve later without a backfill. Nullable: non-HTTP actions and older
* rows have no user agent. varchar(512) comfortably fits real-world UA strings.
*/
export class AddUserAgentToAdminLogs1781308800000 implements MigrationInterface {
name = 'AddUserAgentToAdminLogs1781308800000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "admin_logs" ADD COLUMN IF NOT EXISTS "user_agent" character varying(512)`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "admin_logs" DROP COLUMN IF EXISTS "user_agent"`);
}
}
Loading
Loading