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

Commit dde8c45

Browse files
authored
feat(cli): add participant and generate checklist CLI commands (#387)
1 parent 9c8f28b commit dde8c45

File tree

16 files changed

+692
-5
lines changed

16 files changed

+692
-5
lines changed

.eslintrc.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ module.exports = {
166166
files: ['**/*Slice.ts'],
167167
rules: { 'no-param-reassign': 0 },
168168
},
169+
{
170+
// CLI scripts.
171+
files: ['packages/cli/**'],
172+
rules: { 'no-restricted-syntax': 0, 'no-await-in-loop': 0 },
173+
},
169174
{
170175
// Enable plugins rules only for test files.
171176
files: [

.github/workflows/pull-request.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jobs:
2020
core
2121
shared
2222
deps
23+
cli
2324
requireScope: false
2425
validateSingleCommit: true
2526
subjectPattern: ^(?![A-Z]).+$

packages/cli/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
PROCESS_ST_CHECKLIST_URL=""
2+
3+
SUPABASE_URL=""
4+
SUPABASE_ANON_KEY=""

packages/cli/package.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@coderscamp/cli",
3+
"version": "0.0.1",
4+
"private": true,
5+
"scripts": {
6+
"invoke": "rimraf dist && tsc && node dist/cli/src/index.js"
7+
},
8+
"dependencies": {
9+
"@supabase/supabase-js": "1.24.0",
10+
"class-transformer": "0.4.0",
11+
"class-validator": "0.13.1",
12+
"commander": "8.2.0",
13+
"csv-parse": "4.16.3",
14+
"dotenv": "10.0.0",
15+
"generate-password": "1.6.1",
16+
"puppeteer": "10.4.0",
17+
"reflect-metadata": "0.1.13",
18+
"winston": "3.3.3"
19+
}
20+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { Command } from 'commander';
2+
import puppeteer from 'puppeteer';
3+
4+
import { getUsersByRole, updateUserById } from '../shared/db';
5+
import { env, validateEnv } from '../shared/env';
6+
import { createLogger } from '../shared/logger';
7+
import { userRoles } from '../shared/models';
8+
9+
const logger = createLogger('generate-checklists');
10+
11+
export const generateProcessStChecklist = async (name: string) => {
12+
logger.debug(`Generating new checklist for the user ${name}`);
13+
14+
const urlBase = env.PROCESS_ST_CHECKLIST_URL;
15+
const url = `${urlBase}?checklist_name=${encodeURIComponent(name)}`;
16+
17+
logger.debug('Launching the Puppeteer browser');
18+
19+
const browser = await puppeteer.launch();
20+
21+
try {
22+
const page = await browser.newPage();
23+
24+
logger.debug('Opening provided URL', { url });
25+
await page.goto(url);
26+
27+
logger.debug('Waiting for checklist to be generated');
28+
await page.waitForSelector('.steps-header');
29+
30+
const checklistUrl = page.url();
31+
32+
logger.debug('Page url retrieved. Closing the browser');
33+
await browser.close();
34+
35+
return checklistUrl;
36+
} catch (ex) {
37+
logger.debug(`An error ocurred when generating the checklist for user ${name}`, ex);
38+
await browser.close();
39+
throw ex;
40+
}
41+
};
42+
43+
export const generateChecklists = (program: Command) => {
44+
program
45+
.command('generate-checklists')
46+
.description('Cerates checklists on Process.st for all participants')
47+
.action(async () => {
48+
try {
49+
await validateEnv();
50+
51+
const participants = await getUsersByRole(userRoles.participant);
52+
53+
logger.debug('Iterating through fetched participants');
54+
55+
for (const participant of participants) {
56+
const checklist = await generateProcessStChecklist(participant.name);
57+
58+
await updateUserById(participant.id, { checklist });
59+
}
60+
61+
logger.debug('Iteration through fetched participants finished');
62+
} catch (ex) {
63+
logger.error(ex);
64+
}
65+
});
66+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Argument, Command } from 'commander';
2+
3+
import { getCsvContent } from '../shared/csv';
4+
import { insertUsers, register } from '../shared/db';
5+
import { validateEnv } from '../shared/env';
6+
import { createLogger } from '../shared/logger';
7+
import { ParticipantCsvRow, RegisterDTO, User, userRoles } from '../shared/models';
8+
import { transformToMatchClass } from '../shared/object';
9+
10+
const logger = createLogger('register-participants');
11+
12+
export const registerParticipants = (program: Command) => {
13+
program
14+
.command('register-participants')
15+
.description('Creates accounts for CodersCamp participants listed in the CSV file')
16+
.addArgument(new Argument('<csv-path>', 'Path to the CSV file'))
17+
.action(async (csvPath: string) => {
18+
try {
19+
await validateEnv();
20+
21+
const rows = await getCsvContent(csvPath);
22+
const participantsRows = await Promise.all(rows.map(transformToMatchClass(ParticipantCsvRow)));
23+
24+
const participants: User[] = [];
25+
26+
logger.debug('Iterating through parsed rows');
27+
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+
});
37+
38+
participants.push(participant);
39+
}
40+
41+
logger.debug('Iteration through parsed rows finished');
42+
43+
await insertUsers(participants);
44+
} catch (ex) {
45+
logger.error(ex);
46+
}
47+
});
48+
};

packages/cli/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import 'reflect-metadata';
2+
3+
import { Command } from 'commander';
4+
5+
import { generateChecklists } from './commands/generate-checklists';
6+
import { registerParticipants } from './commands/register-participants';
7+
8+
const program = new Command();
9+
10+
program.version('0.0.0');
11+
12+
registerParticipants(program);
13+
generateChecklists(program);
14+
15+
program.parse();

packages/cli/src/shared/csv.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import parse from 'csv-parse/lib/sync';
2+
import { readFile } from 'fs/promises';
3+
4+
import { createLogger } from './logger';
5+
6+
const logger = createLogger('CSV Utils');
7+
8+
export const getCsvContent = async (csvPath: string) => {
9+
logger.debug(`reading CSV file for path ${csvPath}`);
10+
11+
const content = await readFile(csvPath, { encoding: 'utf-8' });
12+
13+
logger.debug('parsing content of the CSV file');
14+
15+
const parsedContent: Record<string, unknown>[] = parse(content, { columns: true });
16+
17+
logger.debug('CSV file content parsed successfully');
18+
19+
return parsedContent;
20+
};

packages/cli/src/shared/db.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { createClient } from '@supabase/supabase-js';
2+
import { generate } from 'generate-password';
3+
4+
import { env } from './env';
5+
import { createLogger } from './logger';
6+
import { RegisterDTO, Role, User } from './models';
7+
8+
const logger = createLogger('DB Utils');
9+
10+
const USERS_TABLE_NAME = 'users';
11+
12+
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);
13+
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+
31+
export const getUsersByRole = async (role: Role) => {
32+
logger.debug(`Fetching users with the ${role} role`);
33+
34+
const { data, error } = await supabase.from<User>(USERS_TABLE_NAME).select().eq('role', role);
35+
36+
if (!data) {
37+
throw new Error(error ? error.message : `Unknown error ocurred when getting users from the database`);
38+
}
39+
40+
logger.debug(`Users with the ${role} role fetched successfully`, data);
41+
42+
return data;
43+
};
44+
45+
export const insertUsers = async (users: User[]) => {
46+
logger.debug(`Inserting provided users to the ${USERS_TABLE_NAME} table`, users);
47+
48+
await supabase.from<User>(USERS_TABLE_NAME).insert(users);
49+
50+
logger.debug(`Users inserted to the ${USERS_TABLE_NAME} table`);
51+
};
52+
53+
export const updateUserById = async (id: User['id'], data: Partial<Omit<User, 'id' | 'password'>>) => {
54+
logger.debug(`Updating user with id ${id} using the provided data`, data);
55+
56+
await supabase.from<User>(USERS_TABLE_NAME).update(data).match({ id });
57+
58+
logger.debug(`User with id ${id} updated successfully`);
59+
};

packages/cli/src/shared/env.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Expose, plainToClass } from 'class-transformer';
2+
import { IsInt, IsNotEmpty, IsString, validateOrReject } from 'class-validator';
3+
import dotenv from 'dotenv';
4+
5+
import { createLogger } from './logger';
6+
7+
const logger = createLogger('Env Utils');
8+
9+
dotenv.config();
10+
11+
class EnvVariables {
12+
@Expose()
13+
@IsString()
14+
@IsNotEmpty()
15+
SUPABASE_URL: string;
16+
17+
@Expose()
18+
@IsString()
19+
@IsNotEmpty()
20+
SUPABASE_ANON_KEY: string;
21+
22+
@Expose()
23+
@IsString()
24+
@IsNotEmpty()
25+
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;
46+
}
47+
48+
export const env = plainToClass(EnvVariables, process.env, {
49+
excludeExtraneousValues: true,
50+
enableImplicitConversion: true,
51+
});
52+
53+
export const validateEnv = async () => {
54+
logger.debug('Validating env variables');
55+
56+
try {
57+
await validateOrReject(env);
58+
logger.debug('Env variables validated successfully');
59+
} catch (ex) {
60+
logger.error('Error when validating env variables');
61+
throw ex;
62+
}
63+
};

0 commit comments

Comments
 (0)