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

feat(cli): create command to generate CSV with course participants (#399) #400

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ out
# Generated
packages/ui/src/svg
packages/ui/src/icons
packages/cli/output
.cache-loader

# Database
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
"commander": "8.2.0",
"csv-parse": "4.16.3",
"dotenv": "10.0.0",
"generate-password": "1.6.1",
"json2csv": "5.0.6",
"puppeteer": "10.4.0",
"reflect-metadata": "0.1.13",
"winston": "3.3.3"
},
"devDependencies": {
"@types/json2csv": "5.0.3"
}
}
11 changes: 8 additions & 3 deletions packages/cli/src/commands/generate-checklists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,14 @@ export const generateChecklists = (program: Command) => {
logger.debug('Iterating through fetched participants');

for (const participant of participants) {
const checklist = await generateProcessStChecklist(participant.name);

await updateUserById(participant.id, { checklist });
if (!participant.checklist) {
const name = `${participant.firstName} ${participant.lastName}`;
const checklist = await generateProcessStChecklist(name);

await updateUserById(participant.id, { checklist });
} else {
logger.debug('Participant already has a checklist', participant);
}
}

logger.debug('Iteration through fetched participants finished');
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/commands/generate-welcome-csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { isInt, isPositive } from 'class-validator';
import { Argument, Command } from 'commander';

import { parseToCsv } from '../shared/csv';
import { getUsersByRole } from '../shared/db';
import { createWelcomeCsv } from '../shared/files';
import { createLogger } from '../shared/logger';
import { userRoles, WelcomeCsvRow } from '../shared/models';
import { transformAndValidate } from '../shared/object';

const logger = createLogger('generate-welcome-csv');

export const generateWelcomeCsv = (program: Command) => {
program
.command('generate-welcome-csv')
.description('Generates CSV file with names, emails and checklist URLs for all participants in the database')
.addArgument(new Argument('<from-id>', 'Path to the CSV file'))
.action(async (fromId?: string) => {
const startFromId = Number(fromId);

try {
if (fromId && !(isPositive(startFromId) && isInt(startFromId))) {
throw new Error('fromId parameter must be a positive integer');
}

let participants = await getUsersByRole(userRoles.participant);

if (startFromId) {
participants = participants.filter((p) => p.id >= startFromId);
}

const welcomeCsvRows = await Promise.all(participants.map(transformAndValidate(WelcomeCsvRow)));

logger.debug('Parsing welcome CSV objects to CSV format', welcomeCsvRows);

const csv = parseToCsv(welcomeCsvRows);

logger.debug('Creating welcome CSVfile', csv);

await createWelcomeCsv(csv);
} catch (ex) {
logger.error(ex);
}
});
};
47 changes: 29 additions & 18 deletions packages/cli/src/commands/register-participants.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Argument, Command } from 'commander';

import { asyncFilter, removeDuplicatesForProperty } from '../shared/array';
import { getCsvContent } from '../shared/csv';
import { insertUsers, register } from '../shared/db';
import { getUsersByRole, insertUsers } from '../shared/db';
import { validateEnv } from '../shared/env';
import { createLogger } from '../shared/logger';
import { ParticipantCsvRow, RegisterDTO, User, userRoles } from '../shared/models';
import { transformToMatchClass } from '../shared/object';
import { CreateUserDTO, ParticipantCsvRow, userRoles } from '../shared/models';
import { filterInvalid, transformAndValidate, transformToMatchClass } from '../shared/object';

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

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

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

const participants: User[] = [];
const currentParticipants = await getUsersByRole(userRoles.participant);
const currentParticipantsEmails = currentParticipants.map(({ email }) => email);

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

for (const { email, firstName, lastName } of participantsRows) {
const registerDto = await transformToMatchClass(RegisterDTO)({ email });
const userId = await register(registerDto);
const participant = await transformToMatchClass(User)({
...registerDto,
id: userId,
name: `${firstName} ${lastName}`,
role: userRoles.participant,
});
const participantsRowsToAdd = correctParticipantsRows.filter(({ email }) => {
if (currentParticipantsEmails.includes(email)) {
logger.debug(`Participant with email ${email} already exists in the database`);

participants.push(participant);
}
return false;
}

logger.debug('Iteration through parsed rows finished');
return true;
});

await insertUsers(participants);
logger.debug('Mapping ParticipantCsvRows to CreateUserDTOs');

const createUserDTOs = await Promise.all(
participantsRowsToAdd.map(({ email, firstName, lastName }) =>
transformAndValidate(CreateUserDTO)({
email,
firstName,
lastName,
role: userRoles.participant,
}),
),
);

await insertUsers(removeDuplicatesForProperty(createUserDTOs, 'email'));
} catch (ex) {
logger.error(ex);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'reflect-metadata';
import { Command } from 'commander';

import { generateChecklists } from './commands/generate-checklists';
import { generateWelcomeCsv } from './commands/generate-welcome-csv';
import { registerParticipants } from './commands/register-participants';

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

registerParticipants(program);
generateChecklists(program);
generateWelcomeCsv(program);

program.parse();
11 changes: 11 additions & 0 deletions packages/cli/src/shared/array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const asyncFilter = async <Item>(
arr: Item[],
predicate: (item: Item, index: number, arr: Item[]) => Promise<boolean>,
) => {
const results = await Promise.all(arr.map(predicate));

return arr.filter((_value, i) => results[i]);
};

export const removeDuplicatesForProperty = <Item>(arr: Item[], property: keyof Item) =>
arr.filter((value, i, array) => array.findIndex((t) => t[property] === value[property]) === i);
6 changes: 4 additions & 2 deletions packages/cli/src/shared/csv.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import parse from 'csv-parse/lib/sync';
import parseFromCsv from 'csv-parse/lib/sync';
import { readFile } from 'fs/promises';

import { createLogger } from './logger';

export { parse as parseToCsv } from 'json2csv';

const logger = createLogger('CSV Utils');

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

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

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

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

Expand Down
36 changes: 13 additions & 23 deletions packages/cli/src/shared/db.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,21 @@
import { createClient } from '@supabase/supabase-js';
import { generate } from 'generate-password';

import { env } from './env';
import { createLogger } from './logger';
import { RegisterDTO, Role, User } from './models';
import { CreateUserDTO, Role, User } from './models';

const logger = createLogger('DB Utils');

const USERS_TABLE_NAME = 'users';

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

export const register = async (registerDto: RegisterDTO): Promise<User['id']> => {
logger.debug(`Registering user with email ${registerDto.email}`);

const { user, error } = await supabase.auth.signUp({
email: registerDto.email,
password: generate({ length: 16, numbers: true, symbols: true }),
});

if (!user) {
throw error ?? new Error(`Unknown error ocurred when signing up user with email ${registerDto.email}`);
}

logger.debug(`User with email ${registerDto.email} registered`, { id: user.id });

return user.id;
};

export const getUsersByRole = async (role: Role) => {
logger.debug(`Fetching users with the ${role} role`);

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

if (!data) {
if (!data || error) {
throw new Error(error ? error.message : `Unknown error ocurred when getting users from the database`);
}

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

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

await supabase.from<User>(USERS_TABLE_NAME).insert(users);
const { error } = await supabase.from<User>(USERS_TABLE_NAME).insert(users);

if (error) {
throw new Error(error.message);
}

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

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

await supabase.from<User>(USERS_TABLE_NAME).update(data).match({ id });
const { error } = await supabase.from<User>(USERS_TABLE_NAME).update(data).match({ id });

if (error) {
throw new Error(error.message);
}

logger.debug(`User with id ${id} updated successfully`);
};
22 changes: 1 addition & 21 deletions packages/cli/src/shared/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Expose, plainToClass } from 'class-transformer';
import { IsInt, IsNotEmpty, IsString, validateOrReject } from 'class-validator';
import { IsNotEmpty, IsString, validateOrReject } from 'class-validator';
import dotenv from 'dotenv';

import { createLogger } from './logger';
Expand All @@ -23,26 +23,6 @@ class EnvVariables {
@IsString()
@IsNotEmpty()
PROCESS_ST_CHECKLIST_URL: string;

@Expose()
@IsInt()
// @IsNotEmpty()
NODEMAILER_PORT: number;

@Expose()
@IsString()
// @IsNotEmpty()
NODEMAILER_HOST: string;

@Expose()
@IsString()
// @IsNotEmpty()
NODEMAILER_USER: string;

@Expose()
@IsString()
// @IsNotEmpty()
NODEMAILER_PASSWORD: string;
}

export const env = plainToClass(EnvVariables, process.env, {
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/shared/files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { existsSync } from 'fs';
import { mkdir, writeFile } from 'fs/promises';

import { createLogger } from './logger';

const logger = createLogger('Files Utils');

const createOutputDir = async () => {
if (!existsSync('output')) {
await mkdir('output');
}
};

export const createPasswordsList = () => {
const passwords: Record<string, string> = {};

const add = ({ email, password }: { email: string; password: string }) => {
logger.debug(`Adding password for email ${email}`);
passwords[email] = password;
};

const save = async () => {
await createOutputDir();

const path = 'output/passwords.json';

logger.debug(`Saving passwords object to ${path}`);
await writeFile(path, JSON.stringify(passwords), 'utf-8');
logger.debug('Passwords saved successfully');
};

return { add, save };
};

export const createWelcomeCsv = async (csv: string) => {
await createOutputDir();

const path = 'output/welcome.csv';

logger.debug(`Saving welcome CSV to ${path}`);
await writeFile(path, csv, 'utf-8');
logger.debug('Welcome CSV saved successfully');
};
Loading