Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d9b8aee
feat: add export button to audiences header for exporting audience data
timosville Mar 23, 2026
3c2d41e
feat: add AudienceRow component import and update exports in index.ts
timosville Mar 23, 2026
a92d409
feat: implement CSV export functionality for audience data and update…
timosville Mar 23, 2026
30262b7
fix: update import path for getCredentialsFrom to include .js extension
timosville Mar 23, 2026
87c2d81
fix: correct template syntax for filename and Content-Disposition hea…
timosville Mar 23, 2026
1190fed
refactor: hide export button if no audiences
timosville Mar 23, 2026
313ce18
ops: house keeping
timosville Mar 23, 2026
d9591ea
fix: prevent defined env variables being knocked out
timosville Mar 23, 2026
53bd0c8
refactor: rename CSV export methods
timosville Mar 23, 2026
8c750d9
feat: add admin user variant to audiences index story and update expo…
timosville Mar 24, 2026
640f5c3
ops: remove AudienceRow component from exports in index.ts
timosville Mar 24, 2026
87e87df
ops: comment out export button and related methods in audiences index
timosville Mar 24, 2026
82629c3
feat: implement audience export functionality with loading state and …
timosville Mar 24, 2026
d8936f7
feat: update CSV export filename to include application name for bett…
timosville Mar 24, 2026
89f9342
feat: implement caching for audience retrieval and add loading state …
timosville Mar 24, 2026
fbdd6c8
refactor: remove unused AudiencesProps interface and simplify audienc…
timosville Mar 24, 2026
80869cb
fix: correct CSV export filename to use the proper underscore format
timosville Mar 24, 2026
01d06c2
fix: name functions correctly
timosville Mar 24, 2026
95f3411
refactor: remove AudienceRow import from index.ts
timosville Mar 24, 2026
c2da620
refactor: reintroduce AudienceRow import in index.ts for future use
timosville Mar 25, 2026
81daa18
ops: rename function
timosville Mar 25, 2026
d2c0b82
feat: add pagination and metadata handling for audience listing in Au…
timosville Mar 25, 2026
4619494
refactor: update date formatting in audience-row component to handle …
timosville Mar 25, 2026
fc3de3a
ops: house keeping
timosville Mar 25, 2026
c175f7b
Merge branch 'main' into sco-2022-implement-reusable-audience-csv-exp…
timosville Mar 30, 2026
f400b37
refactor: navigator branch merge
JannieT Apr 14, 2026
558378d
refactor: extra column helper functions
JannieT Apr 14, 2026
7357e0b
ops: merge
JannieT Apr 14, 2026
dcc10d1
fix: audiences index props
JannieT Apr 14, 2026
fce51cf
refactor: table title function more generic
JannieT Apr 14, 2026
13c8919
increment: fixes to audiences service and controller
JannieT Apr 14, 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
54 changes: 44 additions & 10 deletions src/backend/configure.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
import type Configure from '@adonisjs/core/commands/configure';
import { Codemods } from '@adonisjs/core/ace/codemods';
import { stubsRoot } from './stubs/main.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { fsReadAll } from '@poppinss/utils';

async function getExistingEnvKeys(appRoot: string): Promise<Set<string>> {
try {
const envPath = join(appRoot, '.env');
const content = await readFile(envPath, 'utf-8');
const keys = new Set<string>();
for (const line of content.split('\n')) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/);
if (match) keys.add(match[1]);
}
return keys;
} catch {
return new Set();
}
}

async function addMissingEnvVariables(
codemods: Codemods,
appRoot: string,
variables: Record<string, string>,
): Promise<void> {
const existing = await getExistingEnvKeys(appRoot);
const toAdd = Object.fromEntries(
Object.entries(variables).filter(([key]) => !existing.has(key)),
);
if (Object.keys(toAdd).length === 0) return;
await codemods.defineEnvVariables(toAdd);
}

async function addMigrations(command: Configure, codemods: Codemods) {
const allMigrations = ['base', 'audit', 'drops', 'preferences', 'campaigns'];

Expand Down Expand Up @@ -128,17 +159,20 @@ export async function configure(command: Configure) {
await addMigrations(command, codemods);

/**
* Define environment variables
* Define environment variables (skip if already defined in .env)
*/
await codemods.defineEnvVariables({ MAIL_FROM_ADDRESS: 'ops@scoutredeem.co' });
await codemods.defineEnvVariables({ CLOUDINARY_API_KEY: 'redacted' });
await codemods.defineEnvVariables({ CLOUDINARY_SECRET: 'redacted' });
await codemods.defineEnvVariables({ CLOUDINARY_CLOUD_NAME: 'pending' });
await codemods.defineEnvVariables({ CLOUDINARY_PRESET: 'pending' });
await codemods.defineEnvVariables({ BIBLE_API_KEY: 'redacted' });
await codemods.defineEnvVariables({ OPENAI_API_KEY: 'redacted' });
await codemods.defineEnvVariables({ GOOGLE_APPLICATION_CREDENTIALS_JSON: 'redacted' });
await codemods.defineEnvVariables({ FIREBASE_SERVICE_ACCOUNT_KEY_JSON: 'redacted' });
const appRoot = fileURLToPath(command.app.appRoot);
await addMissingEnvVariables(codemods, appRoot, {
MAIL_FROM_ADDRESS: 'ops@scoutredeem.co',
CLOUDINARY_API_KEY: 'redacted',
CLOUDINARY_SECRET: 'redacted',
CLOUDINARY_CLOUD_NAME: 'pending',
CLOUDINARY_PRESET: 'pending',
BIBLE_API_KEY: 'redacted',
OPENAI_API_KEY: 'redacted',
GOOGLE_APPLICATION_CREDENTIALS_JSON: 'redacted',
FIREBASE_SERVICE_ACCOUNT_KEY_JSON: 'redacted',
});

/**
* Define environment variables validations
Expand Down
57 changes: 46 additions & 11 deletions src/backend/services/audience_service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import admin from 'firebase-admin';
import { type App, type ServiceAccount } from 'firebase-admin/app';
import { type Auth, type UserRecord } from 'firebase-admin/auth';
import { Auth, type UserRecord } from 'firebase-admin/auth';
import { DateTime } from 'luxon';
import type { AudienceMeta, AudiencesUsersPageResponse } from '../../types';
import { getCredentialsFrom } from './helpers.js';

/**
* Page size for audience list.
* Should be less than 1000 because of Firebase Auth API limits.
*/
export const AUDIENCE_LIST_PAGE_SIZE = 800;
import type { AudiencesUsersPageResponse, AudienceMeta } from '../../types.js';
import {
getCredentialsFrom,
standardAudienceKeys,
extraAudienceColumns,
keyToTitle,
} from './helpers.js';

export class AudienceService {
export default class AudienceService {
private app: App;

constructor() {
Expand All @@ -28,15 +28,17 @@ export class AudienceService {
): Promise<AudiencesUsersPageResponse> {
const listUsersResult = await this.getAuthService().listUsers(maxResults, pageToken);

const users = listUsersResult.users.map((userRecord) =>
const users = listUsersResult.users.map((userRecord: UserRecord) =>
this.userRecordToMeta(userRecord),
);

const nextPageToken = listUsersResult.pageToken ?? null;

// Sort the users if there are no more users to fetch
if (nextPageToken === null) {
users.sort((a, b) => b.lastSignInTime.localeCompare(a.lastSignInTime));
users.sort((a: AudienceMeta, b: AudienceMeta) =>
b.lastSignInTime.localeCompare(a.lastSignInTime),
);
}

return {
Expand All @@ -45,6 +47,31 @@ export class AudienceService {
};
}

/**
* Returns a CSV string of all users.
* @returns CSV string
*/
async toCsvFromUsers(): Promise<string> {
const allUsers = await this.getAuthService().listUsers(1000);
const audience = allUsers.users.map((user: UserRecord) =>
this.userRecordToMeta(user),
);

if (audience.length === 0) return '';

const keys = this.getUserKeys(audience[0]);
const headerRow = keys.map((key) => this.escapeCsvField(keyToTitle(key))).join(',');
const dataRows = audience.map((row: AudienceMeta) =>
keys.map((key) => this.escapeCsvField(row[key as keyof AudienceMeta])).join(','),
);

return [headerRow, ...dataRows].join('\n');
}

private getUserKeys(row: AudienceMeta): string[] {
return [...standardAudienceKeys, ...extraAudienceColumns(row)];
}

private userRecordToMeta(userRecord: UserRecord): AudienceMeta {
const signUpDate = userRecord.metadata.creationTime
? DateTime.fromHTTP(userRecord.metadata.creationTime).toISO()
Expand Down Expand Up @@ -89,4 +116,12 @@ export class AudienceService {
private getAuthService(): Auth {
return admin.auth(this.app);
}

private escapeCsvField(value: unknown): string {
const str = value === null || value === undefined ? '' : String(value);
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
}
24 changes: 24 additions & 0 deletions src/backend/services/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AudienceMeta } from '../../types';

export function trimmedErrors(e: any): Record<string, string[]> {
let trimmed = e.messages.reduce((acc: Record<string, string[]>, error: any) => {
const field = error.field;
Expand Down Expand Up @@ -48,3 +50,25 @@ export const getCredentialsFrom = (key: string): any => {
throw new Error(`${key} environment variable is not a valid encoded JSON string.`);
}
};

/** Fixed audience table fields; any other keys on row objects are treated as extra columns. */
const AUDIENCE_META_KEYS = [
'uid',
'name',
'email',
'photoURL',
'signUpDate',
'lastSignInTime',
] as const satisfies readonly (keyof AudienceMeta)[];

export const standardAudienceKeys = new Set<string>(AUDIENCE_META_KEYS);

export const extraAudienceColumns = (user: AudienceMeta) => {
return Object.keys(user).filter((key) => !standardAudienceKeys.has(key));
};

export const keyToTitle = (key: string) =>
key
.replace(/([A-Z])/g, ' $1')
.trim()
.toUpperCase();
22 changes: 20 additions & 2 deletions src/backend/stubs/controllers/audiences_controller.stub
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ import type { HttpContext } from '@adonisjs/core/http';
import {
AudienceMeta,
AudienceService,
AUDIENCE_LIST_PAGE_SIZE,
} from '@story-cms/kit';
import cms from '#services/cms';

/**
* Page size for audience list.
* Should be less than 1000 because of Firebase Auth API limits.
*/
export const AUDIENCE_LIST_PAGE_SIZE = 800;

export default class AudiencesController {
public async index(ctx: HttpContext) {
let audiences: AudienceMeta[] = [];
Expand All @@ -33,7 +38,7 @@ export default class AudiencesController {
return ctx.inertia.render('AudiencesIndex', { ...props });
}

/** Cursor-paginated JSON for infinite scroll (`pageToken` query = previous `nextPageToken`). */
/** Cursor-paginated JSON for infinite scroll (pageToken query = previous nextPageToken). */
public async users(ctx: HttpContext) {
const pageToken = ctx.request.input('pageToken') as string | undefined;
try {
Expand All @@ -44,4 +49,17 @@ export default class AudiencesController {
return ctx.response.ok({ users: [], nextPageToken: null });
}
}

public async exportAudience(ctx: HttpContext) {
const service = new AudienceService();
const csv = await service.toCsvFromUsers();

const appName = cms.config.meta.name.toLowerCase().replace(/ /g, '_');
const filename = {{ '`${appName}_audience.csv`' }};

return ctx.response
.header('Content-Type', 'text/csv')
.header('Content-Disposition', {{ '`attachment; filename="${filename}"`' }})
.send(csv);
}
}
1 change: 1 addition & 0 deletions src/backend/stubs/routes/audience.stub
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ const AudiencesController = () => import('#controllers/audiences_controller');

export default () => {
router.get(':locale/audience/users', [AudiencesController, 'users']);
router.get(':locale/audience/export', [AudiencesController, 'exportAudience']);
router.get(':locale/audience', [AudiencesController, 'index']);
};
12 changes: 12 additions & 0 deletions src/frontend/audiences/audiences-index.story.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,21 @@
:languages="sharedProps.languages"
:language="sharedProps.language"
:errors="sharedProps.errors"
:audiences="audiences"
:bookmarks="sharedProps.bookmarks"
:exclude="[]"
/>
</Variant>
<Variant title="With admin user" :setup-app="miniSidebar">
<AudiencesIndex
:meta="sharedProps.meta"
:user="{ ...sharedProps.user, role: 'admin' }"
:languages="sharedProps.languages"
:language="sharedProps.language"
:errors="sharedProps.errors"
:audiences="audiences"
:bookmarks="sharedProps.bookmarks"
:exclude="[]"
/>
</Variant>
</Story>
Expand Down
82 changes: 58 additions & 24 deletions src/frontend/audiences/audiences-index.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
<template>
<AppLayout>
<template #header>
<ContentHeader title="Audience"> </ContentHeader>
<ContentHeader title="Audience">
<template #actions>
<button
v-if="showExport"
type="button"
:disabled="user.role !== 'admin' || isExporting"
class="w-32 rounded-[38px] border bg-blue-500 px-[15px] py-[9px] text-center text-sm/5 font-medium text-white opacity-80 shadow focus:outline-none focus:ring focus:ring-indigo-500 active:opacity-80 active:[box-shadow:_0px_2px_4px_0px_rgba(0,_0,_0,_0.15)_inset] enabled:hover:bg-blue-400 enabled:hover:shadow-none disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400"
@click.prevent="exportAudiences"
>
{{ isExporting ? 'Exporting...' : 'Export' }}
</button>
</template>
</ContentHeader>
</template>
<div>
<section class="mt-8 flow-root">
Expand Down Expand Up @@ -54,6 +66,7 @@
</tr>
</tbody>
</table>

<div
v-if="cursor != null"
ref="sentinelRef"
Expand Down Expand Up @@ -83,45 +96,66 @@ import type {
} from '../../types';
import { ResponseStatus } from '../../types';
import { useSharedStore } from '../store';
import { extraAudienceColumns, keyToTitle } from '../../backend/services/helpers';

const props = defineProps<AudiencesProps & SharedPageProps>();

/** Fixed audience table fields; any other keys on row objects are treated as extra columns. */
const AUDIENCE_META_KEYS = [
'uid',
'name',
'email',
'photoURL',
'signUpDate',
'lastSignInTime',
] as const satisfies readonly (keyof AudienceMeta)[];

const standardAudienceKeys = new Set<string>(AUDIENCE_META_KEYS);

const audienceRows = ref<AudienceMeta[]>([...props.audiences]);
const cursor = ref<string | null>(props.nextPageToken ?? null);
const loadingMore = ref(false);
const sentinelRef = ref<HTMLElement | null>(null);
let scrollObserver: IntersectionObserver | null = null;
const props = defineProps<SharedPageProps & AudiencesProps>();

const extraColumns = computed(() => {
if (audienceRows.value.length === 0) return [];
const user = audienceRows.value[0];
return Object.keys(user).filter((key) => !standardAudienceKeys.has(key));
return extraAudienceColumns(user);
});

const tableColspan = computed(() => 3 + extraColumns.value.length);

const extraColumnTitles = computed(() => {
return extraColumns.value.map((column): string => {
return column.replace(/([A-Z])/g, ' $1').trim();
});
const columns = extraColumns.value;
return columns.map((column) => keyToTitle(column));
});

const audienceRows = ref<AudienceMeta[]>([...props.audiences]);
const cursor = ref<string | null>(props.nextPageToken ?? null);
const loadingMore = ref(false);
const sentinelRef = ref<HTMLElement | null>(null);
let scrollObserver: IntersectionObserver | null = null;

const shared = useSharedStore();
shared.setFromProps(props);
shared.setCurrentStoryName('');

const showExport = computed(() => audienceRows.value.length > 0);

const isExporting = ref(false);

const exportAudiences = async () => {
isExporting.value = true;

try {
const exportUrl = `/${shared.locale}/audience/export`;
const response = await axios.get(exportUrl, {
responseType: 'blob',
});

const blob = response.data as Blob;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const disposition = response.headers['content-disposition'];
const filename =
disposition?.split('filename=')[1]?.replace(/"/g, '') ?? 'audience_export.csv';
a.download = filename;
a.click();
URL.revokeObjectURL(url);

shared.addMessage(ResponseStatus.Accomplishment, 'Download started');
} catch (error) {
console.error(error);
shared.addMessage(ResponseStatus.Failure, 'Download failed. Contact support.');
} finally {
isExporting.value = false;
}
};

const resetFromProps = () => {
audienceRows.value = [...props.audiences];
cursor.value = props.nextPageToken ?? null;
Expand Down
Loading
Loading