Skip to content
This repository was archived by the owner on Mar 20, 2023. It is now read-only.

Commit b888236

Browse files
committed
feat(cli): create command to generate CSV with course participants (#399)
1 parent 9e1015a commit b888236

File tree

14 files changed

+273
-87
lines changed

14 files changed

+273
-87
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ out
5555
# Generated
5656
packages/ui/src/svg
5757
packages/ui/src/icons
58+
packages/cli/output
5859
.cache-loader
5960

6061
# Database

packages/cli/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
"commander": "8.2.0",
1313
"csv-parse": "4.16.3",
1414
"dotenv": "10.0.0",
15-
"generate-password": "1.6.1",
15+
"json2csv": "5.0.6",
1616
"puppeteer": "10.4.0",
1717
"reflect-metadata": "0.1.13",
1818
"winston": "3.3.3"
19+
},
20+
"devDependencies": {
21+
"@types/json2csv": "5.0.3"
1922
}
2023
}

packages/cli/src/commands/generate-checklists.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,14 @@ export const generateChecklists = (program: Command) => {
5353
logger.debug('Iterating through fetched participants');
5454

5555
for (const participant of participants) {
56-
const checklist = await generateProcessStChecklist(participant.name);
57-
58-
await updateUserById(participant.id, { checklist });
56+
if (!participant.checklist) {
57+
const name = `${participant.firstName} ${participant.lastName}`;
58+
const checklist = await generateProcessStChecklist(name);
59+
60+
await updateUserById(participant.id, { checklist });
61+
} else {
62+
logger.debug('Participant already has a checklist', participant);
63+
}
5964
}
6065

6166
logger.debug('Iteration through fetched participants finished');
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { isInt, isPositive } from 'class-validator';
2+
import { Argument, Command } from 'commander';
3+
4+
import { parseToCsv } from '../shared/csv';
5+
import { getUsersByRole } from '../shared/db';
6+
import { createWelcomeCsv } from '../shared/files';
7+
import { createLogger } from '../shared/logger';
8+
import { userRoles, WelcomeCsvRow } from '../shared/models';
9+
import { transformAndValidate } from '../shared/object';
10+
11+
const logger = createLogger('generate-welcome-csv');
12+
13+
export const generateWelcomeCsv = (program: Command) => {
14+
program
15+
.command('generate-welcome-csv')
16+
.description('Generates CSV file with names, emails and checklist URLs for all participants in the database')
17+
.addArgument(new Argument('<from-id>', 'Path to the CSV file'))
18+
.action(async (fromId?: string) => {
19+
const startFromId = Number(fromId);
20+
21+
try {
22+
if (fromId && !(isPositive(startFromId) && isInt(startFromId))) {
23+
throw new Error('fromId parameter must be a positive integer');
24+
}
25+
26+
let participants = await getUsersByRole(userRoles.participant);
27+
28+
if (startFromId) {
29+
participants = participants.filter((p) => p.id >= startFromId);
30+
}
31+
32+
const welcomeCsvRows = await Promise.all(participants.map(transformAndValidate(WelcomeCsvRow)));
33+
34+
logger.debug('Parsing welcome CSV objects to CSV format', welcomeCsvRows);
35+
36+
const csv = parseToCsv(welcomeCsvRows);
37+
38+
logger.debug('Creating welcome CSVfile', csv);
39+
40+
await createWelcomeCsv(csv);
41+
} catch (ex) {
42+
logger.error(ex);
43+
}
44+
});
45+
};

packages/cli/src/commands/register-participants.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { Argument, Command } from 'commander';
22

3+
import { asyncFilter, removeDuplicatesForProperty } from '../shared/array';
34
import { getCsvContent } from '../shared/csv';
4-
import { insertUsers, register } from '../shared/db';
5+
import { getUsersByRole, insertUsers } from '../shared/db';
56
import { validateEnv } from '../shared/env';
67
import { createLogger } from '../shared/logger';
7-
import { ParticipantCsvRow, RegisterDTO, User, userRoles } from '../shared/models';
8-
import { transformToMatchClass } from '../shared/object';
8+
import { CreateUserDTO, ParticipantCsvRow, userRoles } from '../shared/models';
9+
import { filterInvalid, transformAndValidate, transformToMatchClass } from '../shared/object';
910

1011
const logger = createLogger('register-participants');
1112

@@ -20,27 +21,37 @@ export const registerParticipants = (program: Command) => {
2021

2122
const rows = await getCsvContent(csvPath);
2223
const participantsRows = await Promise.all(rows.map(transformToMatchClass(ParticipantCsvRow)));
24+
const correctParticipantsRows = await asyncFilter(participantsRows, filterInvalid(ParticipantCsvRow));
2325

24-
const participants: User[] = [];
26+
const currentParticipants = await getUsersByRole(userRoles.participant);
27+
const currentParticipantsEmails = currentParticipants.map(({ email }) => email);
2528

26-
logger.debug('Iterating through parsed rows');
29+
logger.debug('Filtering emails that are already added to the database');
2730

28-
for (const { email, firstName, lastName } of participantsRows) {
29-
const registerDto = await transformToMatchClass(RegisterDTO)({ email });
30-
const userId = await register(registerDto);
31-
const participant = await transformToMatchClass(User)({
32-
...registerDto,
33-
id: userId,
34-
name: `${firstName} ${lastName}`,
35-
role: userRoles.participant,
36-
});
31+
const participantsRowsToAdd = correctParticipantsRows.filter(({ email }) => {
32+
if (currentParticipantsEmails.includes(email)) {
33+
logger.debug(`Participant with email ${email} already exists in the database`);
3734

38-
participants.push(participant);
39-
}
35+
return false;
36+
}
4037

41-
logger.debug('Iteration through parsed rows finished');
38+
return true;
39+
});
4240

43-
await insertUsers(participants);
41+
logger.debug('Mapping ParticipantCsvRows to CreateUserDTOs');
42+
43+
const createUserDTOs = await Promise.all(
44+
participantsRowsToAdd.map(({ email, firstName, lastName }) =>
45+
transformAndValidate(CreateUserDTO)({
46+
email,
47+
firstName,
48+
lastName,
49+
role: userRoles.participant,
50+
}),
51+
),
52+
);
53+
54+
await insertUsers(removeDuplicatesForProperty(createUserDTOs, 'email'));
4455
} catch (ex) {
4556
logger.error(ex);
4657
}

packages/cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'reflect-metadata';
33
import { Command } from 'commander';
44

55
import { generateChecklists } from './commands/generate-checklists';
6+
import { generateWelcomeCsv } from './commands/generate-welcome-csv';
67
import { registerParticipants } from './commands/register-participants';
78

89
const program = new Command();
@@ -11,5 +12,6 @@ program.version('0.0.0');
1112

1213
registerParticipants(program);
1314
generateChecklists(program);
15+
generateWelcomeCsv(program);
1416

1517
program.parse();

packages/cli/src/shared/array.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const asyncFilter = async <Item>(
2+
arr: Item[],
3+
predicate: (item: Item, index: number, arr: Item[]) => Promise<boolean>,
4+
) => {
5+
const results = await Promise.all(arr.map(predicate));
6+
7+
return arr.filter((_value, i) => results[i]);
8+
};
9+
10+
export const removeDuplicatesForProperty = <Item>(arr: Item[], property: keyof Item) =>
11+
arr.filter((value, i, array) => array.findIndex((t) => t[property] === value[property]) === i);

packages/cli/src/shared/csv.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import parse from 'csv-parse/lib/sync';
1+
import parseFromCsv from 'csv-parse/lib/sync';
22
import { readFile } from 'fs/promises';
33

44
import { createLogger } from './logger';
55

6+
export { parse as parseToCsv } from 'json2csv';
7+
68
const logger = createLogger('CSV Utils');
79

810
export const getCsvContent = async (csvPath: string) => {
@@ -12,7 +14,7 @@ export const getCsvContent = async (csvPath: string) => {
1214

1315
logger.debug('parsing content of the CSV file');
1416

15-
const parsedContent: Record<string, unknown>[] = parse(content, { columns: true });
17+
const parsedContent: Record<string, unknown>[] = parseFromCsv(content, { columns: true });
1618

1719
logger.debug('CSV file content parsed successfully');
1820

packages/cli/src/shared/db.ts

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,21 @@
11
import { createClient } from '@supabase/supabase-js';
2-
import { generate } from 'generate-password';
32

43
import { env } from './env';
54
import { createLogger } from './logger';
6-
import { RegisterDTO, Role, User } from './models';
5+
import { CreateUserDTO, Role, User } from './models';
76

87
const logger = createLogger('DB Utils');
98

109
const USERS_TABLE_NAME = 'users';
1110

1211
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);
1312

14-
export const register = async (registerDto: RegisterDTO): Promise<User['id']> => {
15-
logger.debug(`Registering user with email ${registerDto.email}`);
16-
17-
const { user, error } = await supabase.auth.signUp({
18-
email: registerDto.email,
19-
password: generate({ length: 16, numbers: true, symbols: true }),
20-
});
21-
22-
if (!user) {
23-
throw error ?? new Error(`Unknown error ocurred when signing up user with email ${registerDto.email}`);
24-
}
25-
26-
logger.debug(`User with email ${registerDto.email} registered`, { id: user.id });
27-
28-
return user.id;
29-
};
30-
3113
export const getUsersByRole = async (role: Role) => {
3214
logger.debug(`Fetching users with the ${role} role`);
3315

3416
const { data, error } = await supabase.from<User>(USERS_TABLE_NAME).select().eq('role', role);
3517

36-
if (!data) {
18+
if (!data || error) {
3719
throw new Error(error ? error.message : `Unknown error ocurred when getting users from the database`);
3820
}
3921

@@ -42,18 +24,26 @@ export const getUsersByRole = async (role: Role) => {
4224
return data;
4325
};
4426

45-
export const insertUsers = async (users: User[]) => {
27+
export const insertUsers = async (users: CreateUserDTO[]) => {
4628
logger.debug(`Inserting provided users to the ${USERS_TABLE_NAME} table`, users);
4729

48-
await supabase.from<User>(USERS_TABLE_NAME).insert(users);
30+
const { error } = await supabase.from<User>(USERS_TABLE_NAME).insert(users);
31+
32+
if (error) {
33+
throw new Error(error.message);
34+
}
4935

5036
logger.debug(`Users inserted to the ${USERS_TABLE_NAME} table`);
5137
};
5238

5339
export const updateUserById = async (id: User['id'], data: Partial<Omit<User, 'id' | 'password'>>) => {
5440
logger.debug(`Updating user with id ${id} using the provided data`, data);
5541

56-
await supabase.from<User>(USERS_TABLE_NAME).update(data).match({ id });
42+
const { error } = await supabase.from<User>(USERS_TABLE_NAME).update(data).match({ id });
43+
44+
if (error) {
45+
throw new Error(error.message);
46+
}
5747

5848
logger.debug(`User with id ${id} updated successfully`);
5949
};

packages/cli/src/shared/env.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Expose, plainToClass } from 'class-transformer';
2-
import { IsInt, IsNotEmpty, IsString, validateOrReject } from 'class-validator';
2+
import { IsNotEmpty, IsString, validateOrReject } from 'class-validator';
33
import dotenv from 'dotenv';
44

55
import { createLogger } from './logger';
@@ -23,26 +23,6 @@ class EnvVariables {
2323
@IsString()
2424
@IsNotEmpty()
2525
PROCESS_ST_CHECKLIST_URL: string;
26-
27-
@Expose()
28-
@IsInt()
29-
// @IsNotEmpty()
30-
NODEMAILER_PORT: number;
31-
32-
@Expose()
33-
@IsString()
34-
// @IsNotEmpty()
35-
NODEMAILER_HOST: string;
36-
37-
@Expose()
38-
@IsString()
39-
// @IsNotEmpty()
40-
NODEMAILER_USER: string;
41-
42-
@Expose()
43-
@IsString()
44-
// @IsNotEmpty()
45-
NODEMAILER_PASSWORD: string;
4626
}
4727

4828
export const env = plainToClass(EnvVariables, process.env, {

0 commit comments

Comments
 (0)