Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fullstack interview assignment submission #109

Open
wants to merge 4 commits 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
6 changes: 6 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
POSTGRES_PASSWORD=contra_password
POSTGRES_DB=contra_ff
POSTGRES_USER=contra
POSTGRES_ADDRESS=localhost:5432
NODE_ENV=development
ROARR_LOG=true
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ src/types/api.ts
!.yarn/releases
!.yarn/sdks
!.yarn/versions
!.yarnrc.yml
!.yarnrc.yml
!.env.sample
2 changes: 1 addition & 1 deletion backend/.eslintignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
coverage
dist
node_modules
src/generated
src/generated
12 changes: 9 additions & 3 deletions backend/codegen.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
schema:
- src/**/*.graphql
- src/schema/schema.graphql
generates:
'src/generated/types.ts':
src/generated/types.ts:
plugins:
- typescript
- typescript-resolvers
- typescript-operations
- typescript-graphql-request
documents:
- src/schema/operations/*.graphql
config:
allowParentTypeOverride: true
contextType: '../ResolverContextType#ResolverContext'
Expand All @@ -14,6 +18,8 @@ generates:
optionalResolveType: true
useIndexSignature: true
scalars:
ID: 'string | number'
ID: 'number'
Timestamp: 'string'
JSON: '../ResolverContextType#JsonScalar'
useTypeImports: true
mappers:
5 changes: 5 additions & 0 deletions backend/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
verbose: true,
testEnvironment: 'node',
};
27 changes: 19 additions & 8 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -1,46 +1,57 @@
{
"name": "contra-interview-assessment",
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"private": true,
"scripts": {
"dev": "NODE_ENV=development ROARR_LOG=true ts-node-dev --respawn ./src/bin/server.ts | roarr pretty-print",
"dev": "ts-node-dev --respawn ./src/bin/server.ts | roarr pretty-print",
"format-graphql-schema": "format-graphql --write true ./src/schema/schema.graphql",
"build": "tsc --project ./",
"lint": "eslint --fix src . && prettier --write src . && tsc --noEmit && format-graphql --write true src/schema/schema.graphql",
"generate-types": "graphql-codegen",
"migrate": "ts-node ./src/bin/migrate.ts"
"codegen": "graphql-codegen",
"migrate": "ts-node ./src/bin/migrate.ts",
"migrate:up": "ts-node ./src/bin/migrate.ts up",
"seed": "ts-node ./src/bin/seed/index.ts",
"test": "jest"
},
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@graphql-codegen/cli": "1.21.2",
"@graphql-codegen/core": "1.17.9",
"@graphql-codegen/typescript": "1.21.1",
"@graphql-codegen/typescript-graphql-request": "^4.5.9",
"@graphql-codegen/typescript-operations": "1.17.15",
"@graphql-codegen/typescript-resolvers": "1.19.0",
"@graphql-tools/merge": "^8.4.1",
"@graphql-tools/schema": "^9.0.19",
"@roarr/cli": "^5.11.0",
"@slonik/migrator": "0.11.2",
"@typescript-eslint/parser": "5.12.0",
"dotenv-cli": "^7.2.1",
"eslint": "7.22.0",
"eslint-config-canonical": "25.9.1",
"eslint-config-prettier": "7.2.0",
"eslint-plugin-import": "2.22.1",
"format-graphql": "1.4.0",
"graphql-request": "5.1.0",
"jest": "^29.5.0",
"lint-staged": "10.5.4",
"nyc": "15.1.0",
"prettier": "2.2.1",
"ts-jest": "^29.1.0",
"ts-node-dev": "1.1.6",
"typescript": "4.5.5"
},
"dependencies": {
"@roarr/cli": "^3.2.2",
"apollo-server-fastify": "3.6.3",
"envalid": "^7.3.1",
"fast-safe-stringify": "^2.1.1",
"fastify": "^3.14.0",
"fastify-cookie": "^5.4.0",
"got": "^11.8.5",
"graphql": "^15.4.0",
"graphql-helix": "^1.13.0",
"graphql-import": "^1.0.2",
"graphql-tools": "^7.0.2",
"roarr": "^3.2.0",
"roarr": "^7.15.0",
"slonik": "^23.5.1",
"slonik-interceptor-field-name-transformation": "^1.5.3",
"slonik-interceptor-preset": "^1.2.10",
Expand Down
11 changes: 11 additions & 0 deletions backend/src/ResolverContextType.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { Logger } from 'roarr';
import { DatabasePoolType } from 'slonik';

export type ResolverContext = {
readonly pool: DatabasePoolType;
readonly log: Logger;
};

export type JsonScalar =
| number
| boolean
| string
| {
[k: string]: JsonScalar;
}
| JsonScalar[];
10 changes: 3 additions & 7 deletions backend/src/bin/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { SlonikMigrator } from '@slonik/migrator';
import { createPool } from 'slonik';
import { getPgConfig } from '../config/databaseConfiguration';

if (!process.env.POSTGRES_CONNECTION_STRING) {
throw new Error(
'Must provide a PG connection string (export POSTGRES_CONNECTION_STRING=value) -- if you need a fresh database, we recommend using Render.com',
);
}

const slonik = createPool(process.env.POSTGRES_CONNECTION_STRING);
const { connectionString } = getPgConfig();
const slonik = createPool(connectionString);

const migrator = new SlonikMigrator({
logger: console,
Expand Down
20 changes: 20 additions & 0 deletions backend/src/bin/migrations/2023.05.03T19.41.54.feature_flag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CREATE TABLE feature_flag (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name text NOT NULL,
description TEXT NOT NULL,
type text NOT NULL CHECK (TYPE IN ('boolean', 'multi')),
value text NOT NULL,
created_at timestamp with time zone DEFAULT NOW(),
updated_at timestamp with time zone DEFAULT NOW()
);

CREATE TABLE user_feature_flag (
id integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id integer REFERENCES user_account(id) on delete cascade,
flag_id integer REFERENCES feature_flag(id) on delete cascade,
value text,
created_at timestamp with time zone DEFAULT NOW(),
updated_at timestamp with time zone DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_user_feature_flag ON user_feature_flag (user_id, flag_id)
Original file line number Diff line number Diff line change
@@ -1 +1 @@
raise 'down migration not implemented'
DROP TABLE IF EXISTS user_account;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DROP INDEX idx_user_feature_flag;
DROP TABLE IF EXISTS user_feature_flag;
DROP TABLE IF EXISTS feature_flag;
47 changes: 47 additions & 0 deletions backend/src/bin/seed/flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { faker } from '@faker-js/faker';
import { sql } from 'slonik';
import { FlagModel } from '../../data/types';
import { FlagTypeEnum } from '../../generated/types';
import { JsonScalar } from '../../ResolverContextType';

export type FlagInsertModel = Pick<
FlagModel,
'type' | 'name' | 'value' | 'description'
>;

export function generateFlags(amount = 1): FlagInsertModel[] {
const createFlag = () => {
const name = faker.commerce.productName();
const description = faker.commerce.productDescription();
const type = faker.helpers.arrayElement(Object.values(FlagTypeEnum));
const value =
type === FlagTypeEnum.Multi
? faker.helpers.arrayElement([
faker.datatype.number(),
faker.word.noun(),
(faker.science.unit() as unknown) as JsonScalar,
])
: faker.datatype.boolean();
return {
name,
description,
type,
value,
};
};

return Array.from({ length: amount }).map(createFlag);
}

export function getFlagsInsertSql(flags: FlagInsertModel[]) {
const values = flags.map(
(flag) =>
sql`(${flag.name}, ${flag.description}, ${flag.type}, ${sql.json(
flag.value,
)})`,
);
return sql`
INSERT INTO feature_flag (name, description, type, value)
VALUES ${sql.join(values, sql`,`)}
`;
}
17 changes: 17 additions & 0 deletions backend/src/bin/seed/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createPool } from 'slonik';
import { getPgConfig } from '../../config/databaseConfiguration';
import { generateUsers, getUsersInsertSql } from './users';
import { generateFlags, getFlagsInsertSql } from './flags';

(async () => {
const { connectionString } = getPgConfig();
const pool = createPool(connectionString);

const users = generateUsers(5);
const flags = generateFlags(5);

await pool.transaction(async (tc) => {
await tc.query(getUsersInsertSql(users));
await tc.query(getFlagsInsertSql(flags));
});
})();
33 changes: 33 additions & 0 deletions backend/src/bin/seed/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { UserModel } from '../../data/types';
import { faker } from '@faker-js/faker';
import { sql } from 'slonik';

export type UserInsertModel = Pick<
UserModel,
'familyName' | 'givenName' | 'emailAddress'
>;

export function generateUsers(amount = 1): UserInsertModel[] {
const createUser = () => {
const givenName = faker.name.firstName();
const familyName = faker.name.lastName();
return {
givenName,
familyName,
emailAddress: faker.internet.email(givenName, familyName),
};
};

return Array.from({ length: amount }).map(createUser);
}

export function getUsersInsertSql(users: UserInsertModel[]) {
const values = users.map(
(user) =>
sql`(${user.givenName}, ${user.familyName}, ${user.emailAddress})`,
);
return sql`
INSERT INTO user_account (given_name, family_name, email_address)
VALUES ${sql.join(values, sql`,`)}
`;
}
20 changes: 9 additions & 11 deletions backend/src/bin/server.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
import Logger from 'roarr';
import { Roarr } from 'roarr';
import { createPool } from 'slonik';
// @ts-expect-error
import { createInterceptors } from 'slonik-interceptor-preset';
import { getPgConfig } from '../config/databaseConfiguration';
import { createFastifyServer } from '../factories/createFastifyServer';

const log = Logger.child({ context: 'bin/server' });
const log = Roarr.child({ context: 'bin/server' });

if (!process.env.POSTGRES_CONNECTION_STRING)
throw new Error(
'Must provide a PG connection string (export POSTGRES_CONNECTION_STRING=value) -- if you need a fresh database, we recommend using Render.com',
);
const { connectionString } = getPgConfig();

const pool = createPool(process.env.POSTGRES_CONNECTION_STRING, {
const pool = createPool(connectionString, {
captureStackTrace: false,
connectionTimeout: 60 * 1_000,
interceptors: createInterceptors(),
});

(async () => {
try {
const app = await createFastifyServer(pool);
const app = await createFastifyServer(pool, log);

app.listen(8_080, () =>
log.info(`🛩 Server ready at http://localhost:8080/graphql`),
);
app.listen(5000, () => {
log.info(`🛩 Server ready at http://localhost:5000/graphql`);
});
} catch (error) {
// eslint-disable-next-line no-console
console.log(error);
Expand Down
18 changes: 18 additions & 0 deletions backend/src/config/databaseConfiguration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { cleanEnv, str } from 'envalid';

export type PostgresConfiguration = {
connectionString: string;
};

export function getPgConfig(): PostgresConfiguration {
const pgEnv = cleanEnv(process.env, {
POSTGRES_PASSWORD: str({ devDefault: 'contra_password' }),
POSTGRES_DB: str({ devDefault: 'contra_ff' }),
POSTGRES_USER: str({ devDefault: 'contra' }),
POSTGRES_ADDRESS: str({ devDefault: 'localhost:5432' }),
});

return {
connectionString: `postgres://${pgEnv.POSTGRES_USER}:${pgEnv.POSTGRES_PASSWORD}@${pgEnv.POSTGRES_ADDRESS}/${pgEnv.POSTGRES_DB}?sslmode=disable`,
};
}
Loading