├── .bazelversion ├── misc ├── docs │ ├── development.md │ └── microk8s.md ├── daemon.json ├── assets │ ├── icon.png │ ├── banner.png │ ├── check.svg │ └── progress.svg ├── .vsls.json ├── images │ └── bazelisk.Dockerfile └── patches │ ├── typescript+3.9.7.patch │ └── jest-haste-map+26.3.0.patch ├── .bazelignore ├── services ├── authentication │ ├── authentication.errors.ts │ ├── authentication.config.ts │ ├── refresh-token-revoked.ts │ ├── tokens-refreshed.ts │ ├── sign-in-method.ts │ ├── signed-out.ts │ ├── refresh-token.ts │ ├── authentication.rest │ ├── user-read.adapter.ts │ ├── sign-in-confirmed.ts │ ├── sign-in-requested.ts │ ├── session.errors.ts │ ├── google-sign-in-confirmed.ts │ ├── index.ts │ └── google-api.adapter.ts ├── review │ ├── review.yaml │ ├── comment.ts │ ├── review.spec.ts │ ├── review.config.ts │ ├── review-score-changed.ts │ ├── review-published.ts │ ├── review-content-edited.ts │ ├── review.rest │ ├── review-content.ts │ ├── idea-read.adapter.ts │ ├── review-score.ts │ ├── review-read.adapter.ts │ ├── review-created.ts │ └── index.ts ├── search │ ├── search.rest │ ├── index.spec.ts │ ├── search.config.ts │ ├── search.server.ts │ └── index.ts ├── review-read │ ├── review-read.yaml │ ├── index.spec.ts │ ├── review-read.config.ts │ ├── review-read.errors.ts │ ├── review-read.rest │ └── index.ts ├── admin │ ├── index.spec.ts │ ├── index.ts │ ├── admin.yaml │ ├── admin.server.ts │ └── BUILD.bazel ├── gateway │ ├── index.spec.ts │ ├── gateway.config.ts │ ├── root.controller.ts │ ├── search.controller.ts │ └── index.ts ├── mailing │ ├── index.spec.ts │ ├── mailing.config.ts │ ├── mailing.server.ts │ ├── email.service.ts │ ├── user-read.adapter.ts │ └── index.ts ├── idea-read │ ├── index.spec.ts │ ├── idea-read.rest │ ├── idea-read.config.ts │ ├── idea-read.errors.ts │ ├── index.ts │ └── idea-read.server.ts ├── user-read │ ├── index.spec.ts │ ├── user-read.config.ts │ ├── user-read.rest │ ├── user-read.errors.ts │ ├── index.ts │ ├── user.repository.ts │ ├── user-read.yaml │ └── private-user.repository.ts ├── user │ ├── user.config.ts │ ├── email-change-confirmed.ts │ ├── user-renamed.ts │ ├── user-deletion-confirmed.ts │ ├── private-user-deleted.ts │ ├── email-change-requested.ts │ ├── user-deletion-requested.ts │ ├── private-user-created.ts │ ├── user.rest │ ├── user-created.ts │ ├── user-read.adapter.ts │ ├── user.listener.ts │ └── index.ts └── idea │ ├── idea.config.ts │ ├── user-read.adapter.mock.ts │ ├── idea-renamed.ts │ ├── idea-tags-added.ts │ ├── idea-title.ts │ ├── idea-tags-removed.ts │ ├── idea-deleted.ts │ ├── idea-published.ts │ ├── idea-description-edited.ts │ ├── idea-description.ts │ ├── user-read.adapter.ts │ ├── idea-created.ts │ ├── idea-read.adapter.ts │ ├── idea-description.spec.ts │ ├── idea.rest │ ├── idea-title.spec.ts │ ├── index.ts │ ├── idea-tags.ts │ └── idea.yaml ├── packages ├── rpc │ ├── testing │ │ ├── index.ts │ │ ├── BUILD.bazel │ │ └── rpc-client.mock.ts │ ├── constants.ts │ ├── index.ts │ ├── rpc-method.ts │ ├── event-serialization.ts │ ├── BUILD.bazel │ └── rpc-status.ts ├── testing │ ├── index.ts │ ├── index.spec.ts │ ├── BUILD.bazel │ └── expect-async-error.ts ├── enums │ ├── headers.enum.ts │ ├── cookie-names.enum.ts │ ├── environments.ts │ ├── services.enum.ts │ ├── api.ts │ ├── query-params.enum.ts │ ├── review-validation.enum.ts │ ├── user-validation.enum.ts │ ├── expiration-times.enum.ts │ ├── event-topics.enum.ts │ ├── idea-validation.enum.ts │ ├── BUILD.bazel │ ├── rpc-status.enum.ts │ ├── index.ts │ └── events.ts ├── jest │ ├── jest.config.js │ └── BUILD.bazel ├── event-sourcing │ ├── event-id.ts │ ├── snapshot.ts │ ├── constants.ts │ ├── index.spec.ts │ ├── event-store.ts │ ├── snapshot-store.ts │ ├── stream-version.ts │ ├── in-memory-snapshot-store.ts │ ├── replay-version-mismatch.ts │ ├── domain-event.ts │ ├── index.ts │ ├── optimistic-concurrency-issue.ts │ ├── BUILD.bazel │ ├── stream-event.ts │ ├── apply-event.ts │ ├── in-memory-projector.ts │ ├── event-handler.ts │ └── events-handler.ts ├── schemas │ ├── schema.service.ts │ ├── common │ │ ├── common.proto │ │ └── index.ts │ ├── search │ │ ├── index.ts │ │ ├── search.schema.ts │ │ └── search.proto │ ├── index.ts │ ├── serializable-message.ts │ ├── load-proto-service.ts │ ├── idea │ │ ├── idea-read.schema.ts │ │ ├── index.ts │ │ ├── idea-read.proto │ │ ├── idea-commands.schema.ts │ │ ├── idea-events.proto │ │ └── idea-commands.proto │ ├── authentication │ │ ├── index.ts │ │ ├── authentication-commands.schema.ts │ │ ├── authentication-commands.proto │ │ └── authentication-events.proto │ ├── review │ │ ├── review-read.schema.ts │ │ ├── index.ts │ │ ├── review-commands.schema.ts │ │ ├── review-read.proto │ │ ├── review-commands.proto │ │ └── review-events.proto │ ├── event-message-serialization.ts │ ├── user │ │ ├── user-read.schema.ts │ │ ├── user-commands.schema.ts │ │ ├── index.ts │ │ ├── user-commands.proto │ │ ├── user-read.proto │ │ └── user-events.proto │ ├── schema-message.ts │ └── BUILD.bazel ├── types │ ├── index.spec.ts │ ├── tokens │ │ ├── index.ts │ │ ├── user-deletion-token.ts │ │ ├── access-token.ts │ │ ├── change-email-token.ts │ │ ├── token.ts │ │ └── email-sign-in-token.ts │ ├── identifiers.ts │ ├── index.ts │ ├── iso-date.ts │ ├── email.ts │ ├── BUILD.bazel │ ├── id.ts │ ├── event-name.ts │ ├── event-name.spec.ts │ └── username.ts ├── utils │ ├── index.spec.ts │ ├── index.ts │ ├── BUILD.bazel │ ├── exception.ts │ ├── logger.ts │ └── service-server.ts ├── kubernetes │ ├── storage.yaml │ ├── kibana.yaml │ ├── event-store.yaml │ ├── read-database.yaml │ ├── elasticsearch.yaml │ ├── elasticsearch-local.yaml │ ├── certificate-issuer.yaml │ ├── local-ingress.yaml │ ├── ingress.yaml │ ├── kafka-ephemeral.yaml │ ├── kafka-persistent.yaml │ └── BUILD.bazel ├── models │ ├── event.ts │ ├── index.ts │ ├── BUILD.bazel │ ├── token-data.ts │ ├── session.models.ts │ ├── review.models.ts │ ├── idea.models.ts │ └── user.models.ts ├── angular-bazel │ ├── terser.config.json │ ├── tools │ │ ├── angular_ts_library.bzl │ │ ├── insert_html_assets.bzl │ │ ├── pkg_pwa.bzl │ │ └── ngsw_config.bzl │ ├── e2e │ │ ├── pwa-server.on-prepare.js │ │ ├── dev-server.on-prepare.js │ │ └── local-server.on-prepare.js │ ├── rollup.config.js │ ├── BUILD.bazel │ ├── rxjs_shims.js │ └── tslint-base.json ├── config │ ├── mock-config.ts │ ├── BUILD.bazel │ └── index.ts └── dependency-injection │ ├── BUILD.bazel │ └── index.ts ├── .vscode ├── .vsls.json └── settings.json ├── .gitignore ├── env └── .template.secrets.env ├── tsconfig.json ├── tslint.json └── .bazelrc /.bazelversion: -------------------------------------------------------------------------------- 1 | 3.3.1 -------------------------------------------------------------------------------- /misc/docs/development.md: -------------------------------------------------------------------------------- 1 | _coming soon_ 2 | -------------------------------------------------------------------------------- /.bazelignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | bazel-out -------------------------------------------------------------------------------- /services/authentication/authentication.errors.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /services/review/review.yaml: -------------------------------------------------------------------------------- 1 | # TODO review k8s configuration 2 | -------------------------------------------------------------------------------- /packages/rpc/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rpc-client.mock'; 2 | -------------------------------------------------------------------------------- /packages/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './expect-async-error'; 2 | -------------------------------------------------------------------------------- /services/search/search.rest: -------------------------------------------------------------------------------- 1 | GET {{apiUrl}}/search/ideas/marketplace -------------------------------------------------------------------------------- /misc/daemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure-registries": ["localhost:32000"] 3 | } 4 | -------------------------------------------------------------------------------- /services/review-read/review-read.yaml: -------------------------------------------------------------------------------- 1 | # TODO review-read k8s configuration 2 | -------------------------------------------------------------------------------- /services/review/comment.ts: -------------------------------------------------------------------------------- 1 | // TODO comment aggregate (maybe seperate service?) 2 | -------------------------------------------------------------------------------- /misc/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flolu/centsideas/HEAD/misc/assets/icon.png -------------------------------------------------------------------------------- /misc/assets/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flolu/centsideas/HEAD/misc/assets/banner.png -------------------------------------------------------------------------------- /packages/enums/headers.enum.ts: -------------------------------------------------------------------------------- 1 | export enum HeaderKeys { 2 | Auth = 'authorization', 3 | } 4 | -------------------------------------------------------------------------------- /packages/enums/cookie-names.enum.ts: -------------------------------------------------------------------------------- 1 | export enum CookieNames { 2 | RefreshToken = 'rfrsh', 3 | } 4 | -------------------------------------------------------------------------------- /packages/jest/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | rootDir: '../../', 4 | }; 5 | -------------------------------------------------------------------------------- /packages/event-sourcing/event-id.ts: -------------------------------------------------------------------------------- 1 | import {UUId} from '@centsideas/types'; 2 | 3 | export class EventId extends UUId {} 4 | -------------------------------------------------------------------------------- /packages/jest/BUILD.bazel: -------------------------------------------------------------------------------- 1 | package(default_visibility = ["//visibility:public"]) 2 | 3 | exports_files(["jest.config.js"]) 4 | -------------------------------------------------------------------------------- /packages/enums/environments.ts: -------------------------------------------------------------------------------- 1 | export enum Environments { 2 | Dev = 'dev', 3 | Prod = 'prod', 4 | MicroK8s = 'microk8s', 5 | } 6 | -------------------------------------------------------------------------------- /packages/schemas/schema.service.ts: -------------------------------------------------------------------------------- 1 | export interface SchemaService { 2 | proto: string; 3 | package: string; 4 | service: string; 5 | } 6 | -------------------------------------------------------------------------------- /packages/event-sourcing/snapshot.ts: -------------------------------------------------------------------------------- 1 | export interface PersistedSnapshot { 2 | aggregateId: string; 3 | version: number; 4 | data: T; 5 | } 6 | -------------------------------------------------------------------------------- /packages/testing/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/types/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/utils/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './logger'; 2 | export * from './logger'; 3 | export * from './exception'; 4 | export * from './service-server'; 5 | -------------------------------------------------------------------------------- /services/admin/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /services/gateway/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /services/mailing/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /services/search/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /services/idea-read/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /services/review-read/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /services/user-read/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Test Suite', () => { 2 | it('should work', () => { 3 | expect(true).toBeTruthy(); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/enums/services.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Services { 2 | Gateway = 'gateway', 3 | Client = 'client', 4 | Idea = 'idea', 5 | IdeaRead = 'idea-read', 6 | } 7 | -------------------------------------------------------------------------------- /misc/.vsls.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/vsls", 3 | "gitignore": "exclude", 4 | "excludeFiles": ["node_modules", "bazel-out", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/rpc/constants.ts: -------------------------------------------------------------------------------- 1 | export const RPC_CLIENT_FACTORY = Symbol.for('NewFactory'); 2 | export const RPC_SERVER_FACTORY = Symbol.for('Factory'); 3 | -------------------------------------------------------------------------------- /packages/schemas/common/common.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package common; 4 | 5 | message GetEvents { 6 | int32 after = 1; 7 | optional string streamId = 2; 8 | } -------------------------------------------------------------------------------- /services/review/review.spec.ts: -------------------------------------------------------------------------------- 1 | // FIXME review unit tests 2 | describe('Review', () => { 3 | it('should pass', () => { 4 | expect(true).toBe(true); 5 | }); 6 | }); 7 | -------------------------------------------------------------------------------- /.vscode/.vsls.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/vsls", 3 | "excludeFiles": ["*.secrets", "node_modules", "dist", "bazel-out"], 4 | "hideFiles": [] 5 | } 6 | -------------------------------------------------------------------------------- /packages/enums/api.ts: -------------------------------------------------------------------------------- 1 | export enum ApiEndpoints { 2 | Alive = 'alive', 3 | Idea = 'idea', 4 | Authentication = 'auth', 5 | User = 'user', 6 | Review = 'review', 7 | } 8 | -------------------------------------------------------------------------------- /services/idea-read/idea-read.rest: -------------------------------------------------------------------------------- 1 | @id = ___ 2 | 3 | ### 4 | 5 | GET {{apiUrl}}/idea/{{id}} 6 | Authorization: Bearer {{userId}} 7 | 8 | ### 9 | 10 | GET {{apiUrl}}/idea -------------------------------------------------------------------------------- /misc/assets/check.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/enums/query-params.enum.ts: -------------------------------------------------------------------------------- 1 | export enum QueryParamKeys { 2 | Token = 'token', 3 | ConfirmEmailChangeToken = 'confirmEmailChangeToken', 4 | GoogleSignInCode = 'code', 5 | } 6 | -------------------------------------------------------------------------------- /packages/kubernetes/storage.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: storage.k8s.io/v1 2 | kind: StorageClass 3 | metadata: 4 | name: cloud-ssd 5 | provisioner: kubernetes.io/gce-pd 6 | parameters: 7 | type: pd-standard 8 | -------------------------------------------------------------------------------- /packages/enums/review-validation.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ReviewContentLength { 2 | Max = 10000, 3 | Min = 10, 4 | } 5 | 6 | export enum ReviewScoreValue { 7 | Min = 1, 8 | Max = 5, 9 | } 10 | -------------------------------------------------------------------------------- /packages/enums/user-validation.enum.ts: -------------------------------------------------------------------------------- 1 | export enum UsernameLength { 2 | Max = 30, 3 | Min = 3, 4 | } 5 | 6 | export const UsernameRegex = new RegExp('^(?![_.])(?!.*[_.]{2})[a-zA-Z0-9._]+(?'); 2 | export const MONGO_SNAPSHOT_STORE_FACTORY = Symbol.for('Factory'); 3 | -------------------------------------------------------------------------------- /packages/types/tokens/index.ts: -------------------------------------------------------------------------------- 1 | export * from './token'; 2 | export * from './access-token'; 3 | export * from './email-sign-in-token'; 4 | export * from './user-deletion-token'; 5 | export * from './change-email-token'; 6 | -------------------------------------------------------------------------------- /packages/event-sourcing/index.spec.ts: -------------------------------------------------------------------------------- 1 | // FIXME unit tests for whole event sourcing package 2 | 3 | describe('Sample Test Suite', () => { 4 | it('should work', () => { 5 | expect(true).toBeTruthy(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/kubernetes/kibana.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kibana.k8s.elastic.co/v1 2 | kind: Kibana 3 | metadata: 4 | name: kibana 5 | spec: 6 | version: 7.8.0 7 | count: 1 8 | elasticsearchRef: 9 | name: elasticsearch 10 | -------------------------------------------------------------------------------- /packages/rpc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './rpc-server'; 2 | export * from './rpc-client'; 3 | export * from './constants'; 4 | export * from './rpc-status'; 5 | export * from './event-serialization'; 6 | export * from './rpc-method'; 7 | -------------------------------------------------------------------------------- /packages/enums/expiration-times.enum.ts: -------------------------------------------------------------------------------- 1 | export enum TokenExpirationTimes { 2 | SignIn = 2 * 60 * 60, 3 | EmailChange = 2 * 60 * 60, 4 | UserDeletion = 8 * 60 * 60, 5 | Refresh = 7 * 24 * 60 * 60, 6 | Access = 15 * 60, 7 | } 8 | -------------------------------------------------------------------------------- /packages/models/event.ts: -------------------------------------------------------------------------------- 1 | export interface PersistedEvent { 2 | id: string; 3 | streamId: string; 4 | version: number; 5 | name: string; 6 | data: T; 7 | insertedAt: string; 8 | sequence: number; 9 | } 10 | -------------------------------------------------------------------------------- /packages/kubernetes/event-store.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: mongodb.com/v1 2 | kind: MongoDB 3 | metadata: 4 | name: event-store 5 | spec: 6 | members: 3 7 | type: ReplicaSet 8 | version: "4.2.7" 9 | featureCompatibilityVersion: "4.0" 10 | -------------------------------------------------------------------------------- /packages/kubernetes/read-database.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: mongodb.com/v1 2 | kind: MongoDB 3 | metadata: 4 | name: read-database 5 | spec: 6 | members: 3 7 | type: ReplicaSet 8 | version: "4.2.7" 9 | featureCompatibilityVersion: "4.0" 10 | -------------------------------------------------------------------------------- /packages/types/identifiers.ts: -------------------------------------------------------------------------------- 1 | import {ShortId, UUId} from './id'; 2 | 3 | export class IdeaId extends ShortId {} 4 | export class UserId extends ShortId {} 5 | 6 | export class ReviewId extends UUId {} 7 | export class SessionId extends UUId {} 8 | -------------------------------------------------------------------------------- /services/user/user.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import {Config} from '@centsideas/config'; 3 | 4 | @injectable() 5 | export class UserConfig extends Config { 6 | constructor() { 7 | super('user'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bazel-* 3 | coverage 4 | dist 5 | *-config.yaml 6 | secrets.yaml 7 | *.log 8 | 9 | .env 10 | .dev.secrets.env 11 | .microk8s.secrets.env 12 | .prod.secrets.env 13 | .docker-compose.env 14 | 15 | mongodb-kubernetes-operator -------------------------------------------------------------------------------- /services/search/search.config.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '@centsideas/config'; 2 | import {injectable} from 'inversify'; 3 | 4 | @injectable() 5 | export class SearchConfig extends Config { 6 | constructor() { 7 | super('search'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /services/review/review.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {Config} from '@centsideas/config'; 4 | 5 | @injectable() 6 | export class ReviewConfig extends Config { 7 | constructor() { 8 | super('review'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /services/mailing/mailing.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {Config} from '@centsideas/config'; 4 | 5 | @injectable() 6 | export class MailingConfig extends Config { 7 | constructor() { 8 | super('mailing'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /services/user-read/user-read.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import {Config} from '@centsideas/config'; 3 | 4 | @injectable() 5 | export class UserReadConfig extends Config { 6 | constructor() { 7 | super('user-read'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/angular-bazel/terser.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "compress": { 3 | "global_defs": { 4 | "ngDevMode": false, 5 | "__PROD__": true 6 | }, 7 | "passes": 3, 8 | "pure_getters": true 9 | }, 10 | "toplevel": true, 11 | "ecma": 6 12 | } 13 | -------------------------------------------------------------------------------- /packages/schemas/common/index.ts: -------------------------------------------------------------------------------- 1 | import {PersistedEvent} from '@centsideas/models'; 2 | 3 | export interface GetEvents { 4 | after: number; 5 | streamId?: string; 6 | } 7 | 8 | export type GetEventsCommand = (payload: GetEvents) => Promise<{events: PersistedEvent[]}>; 9 | -------------------------------------------------------------------------------- /packages/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './identifiers'; 2 | export * from './iso-date'; 3 | export * from './email'; 4 | export * from './tokens'; 5 | export * from './event-name'; 6 | export * from './username'; 7 | export * from './id'; 8 | export * from './identifiers'; 9 | -------------------------------------------------------------------------------- /services/review-read/review-read.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import {Config} from '@centsideas/config'; 3 | 4 | @injectable() 5 | export class ReviewReadConfig extends Config { 6 | constructor() { 7 | super('review-read'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/enums/event-topics.enum.ts: -------------------------------------------------------------------------------- 1 | export enum EventTopics { 2 | Idea = 'centsideas.events.idea', 3 | Session = 'centsideas.events.session', 4 | User = 'centsideas.events.user', 5 | PrivateUser = 'centsideas.events.privateUser', 6 | Review = 'centsideas.events.review', 7 | } 8 | -------------------------------------------------------------------------------- /packages/models/index.ts: -------------------------------------------------------------------------------- 1 | export * as IdeaModels from './idea.models'; 2 | export * as SessionModels from './session.models'; 3 | export * as UserModels from './user.models'; 4 | export * as ReviewModels from './review.models'; 5 | export * from './event'; 6 | export * from './token-data'; 7 | -------------------------------------------------------------------------------- /packages/schemas/search/index.ts: -------------------------------------------------------------------------------- 1 | import {SchemaService} from '../schema.service'; 2 | 3 | export const SearchService: SchemaService = { 4 | proto: 'search.proto', 5 | package: 'search', 6 | service: 'SearchService', 7 | }; 8 | 9 | export * as SearchQueries from './search.schema'; 10 | -------------------------------------------------------------------------------- /services/authentication/authentication.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {Config} from '@centsideas/config'; 4 | 5 | @injectable() 6 | export class AuthenticationConfig extends Config { 7 | constructor() { 8 | super('authentication'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /services/idea/idea.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {Config} from '@centsideas/config'; 4 | import {Services} from '@centsideas/enums'; 5 | 6 | @injectable() 7 | export class IdeaConfig extends Config { 8 | constructor() { 9 | super(Services.Idea); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /services/user-read/user-read.rest: -------------------------------------------------------------------------------- 1 | @id = ___ 2 | 3 | ### 4 | 5 | GET {{apiUrl}}/user/me 6 | Authorization: Bearer {{userId}} 7 | 8 | ### 9 | 10 | GET {{apiUrl}}/user 11 | 12 | ### 13 | 14 | GET {{apiUrl}}/user/{{id}} 15 | 16 | ### 17 | 18 | GET {{apiUrl}}/personalData 19 | Authorization: Bearer {{userId}} -------------------------------------------------------------------------------- /services/gateway/gateway.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {Config} from '@centsideas/config'; 4 | import {Services} from '@centsideas/enums'; 5 | 6 | @injectable() 7 | export class GatewayConfig extends Config { 8 | constructor() { 9 | super(Services.Gateway); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/enums/idea-validation.enum.ts: -------------------------------------------------------------------------------- 1 | export enum IdeaTitleLength { 2 | Max = 100, 3 | Min = 3, 4 | } 5 | 6 | export enum IdeaTagsCount { 7 | Max = 25, 8 | } 9 | 10 | export enum IdeaTagsLength { 11 | Max = 30, 12 | Min = 2, 13 | } 14 | 15 | export enum IdeaDescriptionLength { 16 | Max = 3000, 17 | } 18 | -------------------------------------------------------------------------------- /services/idea-read/idea-read.config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {Config} from '@centsideas/config'; 4 | import {Services} from '@centsideas/enums'; 5 | 6 | @injectable() 7 | export class IdeaReadConfig extends Config { 8 | constructor() { 9 | super(Services.IdeaRead); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './schema.service'; 3 | export * from './load-proto-service'; 4 | export * from './event-message-serialization'; 5 | 6 | export * from './idea'; 7 | export * from './authentication'; 8 | export * from './user'; 9 | export * from './search'; 10 | export * from './review'; 11 | -------------------------------------------------------------------------------- /packages/angular-bazel/tools/angular_ts_library.bzl: -------------------------------------------------------------------------------- 1 | load("@npm_bazel_typescript//:index.bzl", "ts_library") 2 | 3 | def ng_ts_library(**kwargs): 4 | ts_library( 5 | compiler = "//packages/angular-bazel:tsc_wrapped_with_angular", 6 | supports_workers = True, 7 | use_angular_plugin = True, 8 | **kwargs 9 | ) 10 | -------------------------------------------------------------------------------- /packages/enums/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | 3 | package(default_visibility = ["//visibility:public"]) 4 | 5 | ts_library( 6 | name = "enums", 7 | srcs = glob( 8 | include = ["**/*.ts"], 9 | exclude = ["**/*.spec.ts"], 10 | ), 11 | module_name = "@centsideas/enums", 12 | ) 13 | -------------------------------------------------------------------------------- /packages/schemas/serializable-message.ts: -------------------------------------------------------------------------------- 1 | import {EventTopics} from '@centsideas/enums'; 2 | 3 | export const serializableMessageMap = new Map(); 4 | 5 | export const SerializableMessage = (topic: EventTopics) => { 6 | return function SerializableMessageDecorator(target: any) { 7 | serializableMessageMap.set(topic, new target()); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/kubernetes/elasticsearch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: elasticsearch.k8s.elastic.co/v1 2 | kind: Elasticsearch 3 | metadata: 4 | name: elasticsearch 5 | spec: 6 | version: 7.8.0 7 | nodeSets: 8 | - name: default 9 | count: 3 10 | config: 11 | node.master: true 12 | node.data: true 13 | node.ingest: true 14 | node.store.allow_mmap: false 15 | -------------------------------------------------------------------------------- /packages/rpc/testing/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | 3 | package(default_visibility = ["//services:__subpackages__"]) 4 | 5 | ts_library( 6 | name = "testing", 7 | srcs = glob(["*.ts"]), 8 | module_name = "@centsideas/rpc/testing", 9 | deps = [ 10 | "//packages/schemas", 11 | "@npm//inversify", 12 | ], 13 | ) 14 | -------------------------------------------------------------------------------- /packages/config/mock-config.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | @injectable() 4 | export class MockConfig { 5 | get(_identifier: string, _fallback?: string) { 6 | return ''; 7 | } 8 | 9 | getNumber(_identifier: string, _fallback?: string) { 10 | return 0; 11 | } 12 | 13 | getArray(_identifier: string, _fallback?: string) { 14 | return []; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/event-sourcing/event-store.ts: -------------------------------------------------------------------------------- 1 | import {PersistedEvent} from '@centsideas/models'; 2 | 3 | import {StreamEvents} from './stream-event'; 4 | 5 | export interface EventStore { 6 | getStream(id: string, after?: number): Promise; 7 | getEvents(after: number): Promise; 8 | store(events: StreamEvents, lastVersion: number): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /packages/kubernetes/elasticsearch-local.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: elasticsearch.k8s.elastic.co/v1 2 | kind: Elasticsearch 3 | metadata: 4 | name: elasticsearch 5 | spec: 6 | version: 7.8.0 7 | nodeSets: 8 | - name: default 9 | count: 2 10 | config: 11 | node.master: true 12 | node.data: true 13 | node.ingest: true 14 | node.store.allow_mmap: false 15 | -------------------------------------------------------------------------------- /packages/types/iso-date.ts: -------------------------------------------------------------------------------- 1 | export class Timestamp { 2 | protected constructor(private readonly date: string) {} 3 | 4 | static now() { 5 | return new Timestamp(new Date().toISOString()); 6 | } 7 | 8 | static fromString(isoString: string) { 9 | return new Timestamp(new Date(isoString).toISOString()); 10 | } 11 | 12 | toString() { 13 | return this.date; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/config/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | 3 | package(default_visibility = ["//visibility:public"]) 4 | 5 | ts_library( 6 | name = "config", 7 | srcs = glob(["**/*.ts"]), 8 | module_name = "@centsideas/config", 9 | deps = [ 10 | "@npm//@types/node", 11 | "@npm//dotenv", 12 | "@npm//inversify", 13 | ], 14 | ) 15 | -------------------------------------------------------------------------------- /packages/event-sourcing/snapshot-store.ts: -------------------------------------------------------------------------------- 1 | import {Id} from '@centsideas/types'; 2 | 3 | import {PersistedSnapshot} from './snapshot'; 4 | 5 | export interface SnapshotStore { 6 | get(id: Id): Promise; 7 | store(snapshot: PersistedSnapshot): Promise; 8 | } 9 | 10 | export interface SnapshotStoreFactoryOptions { 11 | url: string; 12 | name: string; 13 | } 14 | -------------------------------------------------------------------------------- /packages/models/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | 3 | package(default_visibility = ["//visibility:public"]) 4 | 5 | ts_library( 6 | name = "models", 7 | srcs = glob( 8 | include = ["**/*.ts"], 9 | exclude = ["**/*.spec.ts"], 10 | ), 11 | module_name = "@centsideas/models", 12 | deps = [ 13 | "//packages/enums", 14 | ], 15 | ) 16 | -------------------------------------------------------------------------------- /services/authentication/refresh-token-revoked.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent} from '@centsideas/event-sourcing'; 2 | import {AuthenticationEventNames} from '@centsideas/enums'; 3 | 4 | @DomainEvent(AuthenticationEventNames.RefreshTokenRevoked) 5 | export class RefreshTokenRevoked { 6 | serialize() { 7 | return {}; 8 | } 9 | 10 | static deserialize() { 11 | return new RefreshTokenRevoked(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /env/.template.secrets.env: -------------------------------------------------------------------------------- 1 | # # # # # # # # # # # 2 | # 🔐 secrets / 3rd party 3 | secrets.tokens.refresh=___ 4 | secrets.tokens.access=___ 5 | secrets.tokens.signin=___ 6 | secrets.tokens.change_email=___ 7 | secrets.tokens.delete_user=___ 8 | secrets.google.client_id=___ 9 | secrets.google.client_secret=___ 10 | secrets.vapid.public=___ 11 | secrets.vapid.private=___ 12 | secrets.sendgrid.api=___ 13 | # # # # # # # # # # # -------------------------------------------------------------------------------- /packages/kubernetes/certificate-issuer.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: cert-manager.io/v1alpha2 2 | kind: ClusterIssuer 3 | metadata: 4 | name: letsencrypt-prod 5 | spec: 6 | acme: 7 | server: https://acme-v02.api.letsencrypt.org/directory 8 | email: flo@flolu.com 9 | privateKeySecretRef: 10 | name: letsencrypt-private-key 11 | solvers: 12 | - http01: 13 | ingress: 14 | class: nginx 15 | -------------------------------------------------------------------------------- /services/authentication/tokens-refreshed.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {AuthenticationEventNames} from '@centsideas/enums'; 3 | 4 | @DomainEvent(AuthenticationEventNames.TokensRefreshed) 5 | export class TokensRefreshed implements IDomainEvent { 6 | serialize() { 7 | return {}; 8 | } 9 | 10 | static deserialize() { 11 | return new TokensRefreshed(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/config/index.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import {Config} from './config'; 3 | 4 | @injectable() 5 | export class GlobalConfig extends Config { 6 | constructor() { 7 | super('global'); 8 | } 9 | } 10 | 11 | @injectable() 12 | export class SecretsConfig extends Config { 13 | constructor() { 14 | super('secrets'); 15 | } 16 | } 17 | 18 | export * from './config'; 19 | export * from './mock-config'; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "strict": true, 5 | "experimentalDecorators": true, 6 | "emitDecoratorMetadata": true, 7 | "target": "es5", 8 | "paths": { 9 | "@centsideas/*": ["./packages/*"], 10 | "@cic/*": ["./services/client/*"] 11 | }, 12 | "types": ["reflect-metadata", "node", "jest"] 13 | }, 14 | "exclude": ["bazel-out", "node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /services/review-read/review-read.errors.ts: -------------------------------------------------------------------------------- 1 | import {Exception} from '@centsideas/utils'; 2 | import {RpcStatus, ReviewErrorNames} from '@centsideas/enums'; 3 | import {ReviewId} from '@centsideas/types'; 4 | 5 | export class NotFound extends Exception { 6 | name = ReviewErrorNames.NotFound; 7 | code = RpcStatus.NOT_FOUND; 8 | constructor(id?: ReviewId) { 9 | super(`Review ${id ? 'with id ' + id : ''} not found`, {id}); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /services/idea-read/idea-read.errors.ts: -------------------------------------------------------------------------------- 1 | import {Exception} from '@centsideas/utils'; 2 | import {RpcStatus, IdeaErrorNames} from '@centsideas/enums'; 3 | import {IdeaId} from '@centsideas/types'; 4 | 5 | export class IdeaNotFound extends Exception { 6 | code = RpcStatus.NOT_FOUND; 7 | name = IdeaErrorNames.NotFound; 8 | 9 | constructor(id?: IdeaId) { 10 | super(`Idea with id ${id ? id : ''} not found`, {id}); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /services/gateway/root.controller.ts: -------------------------------------------------------------------------------- 1 | import {interfaces, controller, httpGet} from 'inversify-express-utils'; 2 | 3 | import {ApiEndpoints} from '@centsideas/enums'; 4 | 5 | @controller('') 6 | export class RootController implements interfaces.Controller { 7 | @httpGet(``) 8 | index() { 9 | return 'centsideas api gateway'; 10 | } 11 | 12 | @httpGet(`/${ApiEndpoints.Alive}`) 13 | alive() { 14 | return 'gateway is alive'; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /services/review-read/review-read.rest: -------------------------------------------------------------------------------- 1 | @ideaId = ueouRZvmF 2 | @authorId = dtUd0B9AD 3 | 4 | ### 5 | 6 | GET {{apiUrl}}/review 7 | 8 | ### 9 | 10 | GET {{apiUrl}}/review?ideaId={{ideaId}} 11 | Authorization: Bearer {{userId}} 12 | 13 | ### 14 | 15 | GET {{apiUrl}}/review?authorId={{authorId}} 16 | Authorization: Bearer {{userId}} 17 | 18 | ### 19 | 20 | GET {{apiUrl}}/review?ideaId={{ideaId}}&authorId={{authorId}} 21 | Authorization: Bearer {{userId}} -------------------------------------------------------------------------------- /packages/enums/rpc-status.enum.ts: -------------------------------------------------------------------------------- 1 | export enum RpcStatus { 2 | OK = 0, 3 | CANCELLED = 1, 4 | UNKNOWN = 2, 5 | INVALID_ARGUMENT = 3, 6 | DEADLINE_EXCEEDED = 4, 7 | NOT_FOUND = 5, 8 | ALREADY_EXISTS = 6, 9 | PERMISSION_DENIED = 7, 10 | RESOURCE_EXHAUSTED = 8, 11 | FAILED_PRECONDITION = 9, 12 | ABORTED = 10, 13 | OUT_OF_RANGE = 11, 14 | UNIMPLEMENTED = 12, 15 | INTERNAL = 13, 16 | UNAVAILABLE = 14, 17 | DATA_LOSS = 15, 18 | UNAUTHENTICATED = 16, 19 | } 20 | -------------------------------------------------------------------------------- /packages/dependency-injection/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | 3 | package(default_visibility = ["//visibility:public"]) 4 | 5 | ts_library( 6 | name = "dependency-injection", 7 | srcs = glob( 8 | include = ["**/*.ts"], 9 | exclude = ["**/*.spec.ts"], 10 | ), 11 | module_name = "@centsideas/dependency-injection", 12 | deps = [ 13 | "@npm//inversify", 14 | "@npm//reflect-metadata", 15 | ], 16 | ) 17 | -------------------------------------------------------------------------------- /packages/angular-bazel/e2e/pwa-server.on-prepare.js: -------------------------------------------------------------------------------- 1 | const protractorUtils = require('@bazel/protractor/protractor-utils'); 2 | const protractor = require('protractor'); 3 | 4 | module.exports = function (config) { 5 | return protractorUtils.runServer(config.workspace, config.server, '-p', []).then(serverSpec => { 6 | const serverUrl = `http://localhost:${serverSpec.port}`; 7 | protractor.browser.baseUrl = serverUrl; 8 | protractor.browser.ignoreSynchronization = true; 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/event-sourcing/stream-version.ts: -------------------------------------------------------------------------------- 1 | export class StreamVersion { 2 | protected constructor(private version: number) {} 3 | 4 | static start() { 5 | return new StreamVersion(0); 6 | } 7 | 8 | static fromNumber(num: number) { 9 | return new StreamVersion(num); 10 | } 11 | 12 | next() { 13 | this.version++; 14 | } 15 | 16 | toNumber() { 17 | return this.version; 18 | } 19 | 20 | copy() { 21 | return StreamVersion.fromNumber(this.version); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/angular-bazel/e2e/dev-server.on-prepare.js: -------------------------------------------------------------------------------- 1 | const protractorUtils = require('@bazel/protractor/protractor-utils'); 2 | const protractor = require('protractor'); 3 | 4 | module.exports = function (config) { 5 | return protractorUtils 6 | .runServer(config.workspace, config.server, '-port', []) 7 | .then(serverSpec => { 8 | protractor.browser.ignoreSynchronization = true; 9 | const serverUrl = `http://localhost:${serverSpec.port}`; 10 | protractor.browser.baseUrl = serverUrl; 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/angular-bazel/e2e/local-server.on-prepare.js: -------------------------------------------------------------------------------- 1 | const protractorUtils = require('@bazel/protractor/protractor-utils'); 2 | const protractor = require('protractor'); 3 | 4 | module.exports = function (config) { 5 | return protractorUtils 6 | .runServer(config.workspace, config.server, '--port', []) 7 | .then(serverSpec => { 8 | const serverUrl = `http://localhost:${serverSpec.port}`; 9 | protractor.browser.baseUrl = serverUrl; 10 | protractor.browser.ignoreSynchronization = true; 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /services/admin/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | // tslint:disable-next-line:no-var-requires 4 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 5 | 6 | import {DI} from '@centsideas/dependency-injection'; 7 | import {Logger} from '@centsideas/utils'; 8 | import {GlobalConfig} from '@centsideas/config'; 9 | 10 | import {AdminServer} from './admin.server'; 11 | 12 | DI.registerProviders(AdminServer); 13 | DI.registerProviders(Logger, GlobalConfig); 14 | 15 | DI.bootstrap(AdminServer); 16 | -------------------------------------------------------------------------------- /services/search/search.server.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {ServiceServer} from '@centsideas/utils'; 4 | 5 | import {SearchProjector} from './search.projector'; 6 | 7 | @injectable() 8 | export class SearchServer extends ServiceServer { 9 | constructor(private projector: SearchProjector) { 10 | super(); 11 | } 12 | 13 | async healthcheck() { 14 | return this.projector.healthcheck(); 15 | } 16 | 17 | async shutdownHandler() { 18 | await this.projector.shutdown(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/angular-bazel/rollup.config.js: -------------------------------------------------------------------------------- 1 | const node = require('rollup-plugin-node-resolve'); 2 | const commonjs = require('rollup-plugin-commonjs'); 3 | 4 | module.exports = { 5 | plugins: [ 6 | node({ 7 | mainFields: ['browser', 'es2015', 'module', 'jsnext:main', 'main'], 8 | }), 9 | commonjs(), 10 | ], 11 | 12 | // https://stackoverflow.com/a/43556986/8586803 13 | onwarn: function (warning) { 14 | if (warning.code === 'THIS_IS_UNDEFINED') return; 15 | console.warn(warning.message); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/schemas/search/search.schema.ts: -------------------------------------------------------------------------------- 1 | export interface SearchIdeaPayload { 2 | input: string; 3 | } 4 | 5 | export interface SearchIdeaResult { 6 | hits: IdeaSearchHit[]; 7 | } 8 | 9 | export interface IdeaSearchHit { 10 | score: number; 11 | id: string; 12 | userId: string; 13 | title: string; 14 | description: string; 15 | tags: string[]; 16 | publishedAt: string; 17 | updatedAt: string; 18 | } 19 | 20 | export interface Service { 21 | searchIdeas: (payload: SearchIdeaPayload) => Promise; 22 | } 23 | -------------------------------------------------------------------------------- /services/authentication/sign-in-method.ts: -------------------------------------------------------------------------------- 1 | export enum SignInMethods { 2 | Email = 'email', 3 | Google = 'google', 4 | } 5 | 6 | export class SignInMethod { 7 | constructor(private readonly method: SignInMethods) {} 8 | 9 | static fromString(method: string) { 10 | if (!Object.values(SignInMethods).includes(method as any)) 11 | throw new Error(`${method} is not a valid SignInMethod!`); 12 | return new SignInMethod(method as SignInMethods); 13 | } 14 | 15 | toString() { 16 | return this.method; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /services/mailing/mailing.server.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {ServiceServer} from '@centsideas/utils'; 4 | 5 | import {MailingService} from './mailing.service'; 6 | 7 | @injectable() 8 | export class MailingServer extends ServiceServer { 9 | constructor(private mailingService: MailingService) { 10 | super(); 11 | } 12 | 13 | async healthcheck() { 14 | return this.mailingService.connected; 15 | } 16 | 17 | async shutdownHandler() { 18 | await this.mailingService.disconnect(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/models/token-data.ts: -------------------------------------------------------------------------------- 1 | export interface INativeTokenData { 2 | iat: number; 3 | exp: number; 4 | } 5 | 6 | export interface IRefreshTokenPayload { 7 | userId: string; 8 | tokenId: string; 9 | } 10 | 11 | export interface ILoginTokenPayload { 12 | loginId: string; 13 | email: string; 14 | firstLogin: boolean; 15 | } 16 | 17 | export interface IAccessTokenPayload { 18 | userId: string; 19 | } 20 | 21 | export interface IEmailChangeTokenPayload { 22 | currentEmail: string; 23 | newEmail: string; 24 | userId: string; 25 | } 26 | -------------------------------------------------------------------------------- /packages/angular-bazel/tools/insert_html_assets.bzl: -------------------------------------------------------------------------------- 1 | load("@npm//html-insert-assets:index.bzl", "html_insert_assets") 2 | 3 | def insert_html_assets(name, outs, html_file, asset_paths, data): 4 | html_insert_assets( 5 | name = name, 6 | outs = outs, 7 | args = [ 8 | "--html", 9 | "$(execpath %s)" % html_file, 10 | "--out", 11 | "$@", 12 | "--roots", 13 | "$(RULEDIR)", 14 | "--assets", 15 | ] + asset_paths, 16 | data = data, 17 | ) 18 | -------------------------------------------------------------------------------- /packages/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api'; 2 | export * from './events'; 3 | export * from './event-topics.enum'; 4 | export * from './headers.enum'; 5 | export * from './query-params.enum'; 6 | export * from './expiration-times.enum'; 7 | export * from './cookie-names.enum'; 8 | export * from './services.enum'; 9 | export * from './environments'; 10 | export * from './rpc-status.enum'; 11 | export * from './errors.enum'; 12 | export * from './idea-validation.enum'; 13 | export * from './user-validation.enum'; 14 | export * from './review-validation.enum'; 15 | -------------------------------------------------------------------------------- /packages/kubernetes/local-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: centsideas-ingress 5 | annotations: 6 | kubernetes.io/tls-acme: "true" 7 | kubernetes.io/ingress.class: "nginx" 8 | nginx.ingress.kubernetes.io/ssl-redirect: "false" 9 | spec: 10 | tls: 11 | - hosts: 12 | - localhost 13 | - api.localhost 14 | rules: 15 | - host: api.localhost 16 | http: 17 | paths: 18 | - backend: 19 | serviceName: gateway 20 | servicePort: 3000 21 | -------------------------------------------------------------------------------- /services/user/email-change-confirmed.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {PrivateUserEventNames} from '@centsideas/enums'; 3 | import {UserModels} from '@centsideas/models'; 4 | 5 | @DomainEvent(PrivateUserEventNames.EmailChangeConfirmed) 6 | export class EmailChangeConfirmed implements IDomainEvent { 7 | serialize(): UserModels.EmailChangeConfirmedData { 8 | return {}; 9 | } 10 | 11 | static deserialize({}: UserModels.EmailChangeConfirmedData) { 12 | return new EmailChangeConfirmed(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/rpc/testing/rpc-client.mock.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {SchemaService, loadProtoService} from '@centsideas/schemas'; 4 | 5 | @injectable() 6 | export class RpcClientMock { 7 | client: T = {} as any; 8 | 9 | initialize(_host: string, service: SchemaService) { 10 | const serviceDefinition = loadProtoService(service); 11 | Object.keys(serviceDefinition.service).forEach( 12 | methodName => 13 | ((this.client as any)[methodName] = () => { 14 | return null; 15 | }), 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /services/idea/user-read.adapter.mock.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {UserId, Timestamp} from '@centsideas/types'; 4 | import {UserModels} from '@centsideas/models'; 5 | 6 | @injectable() 7 | export class UserReadAdapterMock { 8 | async getUserById(user: UserId) { 9 | const mockUser: UserModels.UserView = { 10 | id: user.toString(), 11 | username: 'mock', 12 | createdAt: Timestamp.now().toString(), 13 | updatedAt: Timestamp.now().toString(), 14 | lastEventVersion: 1, 15 | }; 16 | return mockUser; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:latest", "tslint-config-prettier"], 3 | "rules": { 4 | "object-literal-sort-keys": false, 5 | "no-implicit-dependencies": [true, ["@centsideas"]], 6 | "no-submodule-imports": false, 7 | "no-console": [true, "log"], 8 | "no-empty-interface": false, 9 | "forin": false, 10 | "no-unused-variable": [true, {"ignore-pattern": "^_"}], 11 | "max-classes-per-file": false 12 | }, 13 | "linterOptions": { 14 | "exclude": ["node_modules/**", "**/node_modules/**", "**/*.d.ts", "dist/**", "bazel-out/**"] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/rpc/rpc-method.ts: -------------------------------------------------------------------------------- 1 | import {SchemaService} from '@centsideas/schemas'; 2 | 3 | export const RPC_METHODS = '__rpcMethods__'; 4 | 5 | export const RpcMethod = (schemaService: SchemaService) => { 6 | return function MethodDecorator( 7 | _target: object, 8 | methodName: string | symbol, 9 | descriptor: PropertyDescriptor, 10 | ) { 11 | const metadata = Reflect.getMetadata(RPC_METHODS, schemaService); 12 | Reflect.defineMetadata( 13 | RPC_METHODS, 14 | {...metadata, [methodName]: descriptor.value}, 15 | schemaService, 16 | ); 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/schemas/search/search.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package search; 4 | 5 | service SearchService { 6 | rpc searchIdeas(SearchIdeaPayload) returns (IdeasSearchResult); 7 | } 8 | 9 | message SearchIdeaPayload { 10 | string input = 1; 11 | } 12 | 13 | message IdeasSearchResult { 14 | repeated IdeaSearchHits hits = 2; 15 | } 16 | 17 | message IdeaSearchHits { 18 | float score = 1; 19 | string id = 2; 20 | string userId = 3; 21 | string title = 4; 22 | string description = 5; 23 | repeated string tags = 6; 24 | string publishedAt = 7; 25 | string updatedAt = 8; 26 | } -------------------------------------------------------------------------------- /misc/images/bazelisk.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | RUN apt-get update 4 | RUN apt-get -y install curl gnupg unzip python python3 git build-essential 5 | 6 | # nodejs 7 | RUN apt-get -y install nodejs 8 | 9 | # yarn 10 | RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ 11 | echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ 12 | apt-get update && apt-get -y install yarn 13 | 14 | # bazelisk 15 | RUN yarn global add @bazel/bazelisk --prefix /usr/local && bazelisk version 16 | 17 | WORKDIR /app 18 | 19 | ENTRYPOINT [ "bazelisk" ] -------------------------------------------------------------------------------- /packages/kubernetes/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: centsideas-ingress 5 | annotations: 6 | kubernetes.io/tls-acme: "true" 7 | kubernetes.io/ingress.class: "nginx" 8 | cert-manager.io/cluster-issuer: letsencrypt-prod 9 | spec: 10 | tls: 11 | - hosts: 12 | - centsideas.com 13 | - api.centsideas.com 14 | secretName: centsideas-tls 15 | rules: 16 | - host: api.centsideas.com 17 | http: 18 | paths: 19 | - backend: 20 | serviceName: gateway 21 | servicePort: 3000 22 | -------------------------------------------------------------------------------- /.bazelrc: -------------------------------------------------------------------------------- 1 | build --disk_cache=~/.cache/bazel-disk-cache 2 | build --symlink_prefix=dist/ 3 | test --test_output=errors 4 | test:debug --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results --define=VERBOSE_LOGS=1 5 | run:debug --define=VERBOSE_LOGS=1 -- --node_options=--inspect-brk 6 | build:debug --compilation_mode=dbg 7 | build --nolegacy_external_runfiles 8 | common --experimental_allow_incremental_repository_updates 9 | build --incompatible_strict_action_env 10 | run --incompatible_strict_action_env 11 | try-import %workspace%/.bazelrc.user 12 | build --define=angular_ivy_enabled=True -------------------------------------------------------------------------------- /packages/models/session.models.ts: -------------------------------------------------------------------------------- 1 | export interface SignInRequestedData { 2 | sessionId: string; 3 | method: string; 4 | email: string; 5 | requestedAt: string; 6 | } 7 | 8 | export interface SignInConfirmedData { 9 | isSignUp: boolean; 10 | userId: string; 11 | confirmedAt: string; 12 | email: string; 13 | } 14 | 15 | export interface GoogleSignInConfirmedData { 16 | sessionId: string; 17 | userId: string; 18 | email: string; 19 | isSignUp: boolean; 20 | requestedAt: string; 21 | confirmedAt: string; 22 | } 23 | 24 | export interface SignedOutData { 25 | signedOutAt: string; 26 | } 27 | -------------------------------------------------------------------------------- /packages/testing/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | load("@centsideas//packages/jest:jest.bzl", "ts_jest") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | ts_library( 7 | name = "testing", 8 | srcs = glob( 9 | include = ["**/*.ts"], 10 | exclude = ["**/*.spec.ts"], 11 | ), 12 | module_name = "@centsideas/testing", 13 | deps = ["@npm//@types/jest"], 14 | ) 15 | 16 | ts_jest( 17 | name = "test", 18 | srcs = glob(include = ["**/*.spec.ts"]), 19 | test_lib = "testing", 20 | tsconfig = "//:tsconfig.json", 21 | ) 22 | -------------------------------------------------------------------------------- /packages/rpc/event-serialization.ts: -------------------------------------------------------------------------------- 1 | import {PersistedEvent} from '@centsideas/models'; 2 | import {EventName} from '@centsideas/types'; 3 | 4 | export function serializeEvent(event: PersistedEvent): PersistedEvent { 5 | const data = event.data; 6 | const eventName = EventName.fromString(event.name); 7 | return { 8 | ...event, 9 | data: {[eventName.name]: data}, 10 | }; 11 | } 12 | 13 | export function deserializeEvent(event: PersistedEvent): PersistedEvent { 14 | const eventName = EventName.fromString(event.name); 15 | return { 16 | ...event, 17 | data: (event.data as any)[eventName.name], 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rest-client.environmentVariables": { 3 | "$shared": { 4 | "userId": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiIxNDE0NmEwYS0yNTZhLTQ4Y2MtYjU4Ny1jZjNjYjBhNTMwYzciLCJ1c2VySWQiOiJmUEhtaUxxZnkiLCJpYXQiOjE1OTY2MTA2NDksImV4cCI6MTU5NjYxMTU0OX0.28_0xHLEMBE3HhUtI9UozPOHbHZNwqOiGg9GZ4bp0K0" 5 | }, 6 | "dev": { 7 | "apiUrl": "http://localhost:3000" 8 | }, 9 | "microk8s": { 10 | "apiUrl": "http://api.localhost" 11 | }, 12 | "prod": { 13 | "apiUrl": "http://api.centsideas.com" 14 | } 15 | }, 16 | "typescript.tsdk": "node_modules/typescript/lib" 17 | } 18 | -------------------------------------------------------------------------------- /packages/testing/expect-async-error.ts: -------------------------------------------------------------------------------- 1 | export async function expectAsyncError(check: () => Promise, expectedError: any) { 2 | let error; 3 | try { 4 | await check(); 5 | } catch (e) { 6 | error = e; 7 | } 8 | expect(error).toEqual(expectedError); 9 | } 10 | 11 | export async function expectNoAsyncError(check: () => Promise) { 12 | let error; 13 | try { 14 | await check(); 15 | } catch (e) { 16 | error = e; 17 | } 18 | 19 | // tslint:disable-next-line:no-console 20 | if (error) console.log('Received async error although expected no error: ', error); 21 | expect(error).toEqual(undefined); 22 | } 23 | -------------------------------------------------------------------------------- /misc/patches/typescript+3.9.7.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/typescript/lib/typescript.js b/node_modules/typescript/lib/typescript.js 2 | index cb5199f..665d249 100644 3 | --- a/node_modules/typescript/lib/typescript.js 4 | +++ b/node_modules/typescript/lib/typescript.js 5 | @@ -92623,6 +92623,7 @@ var ts; 6 | } 7 | ts.isInternalDeclaration = isInternalDeclaration; 8 | var declarationEmitNodeBuilderFlags = 1024 /* MultilineObjectLiterals */ | 9 | + 67108864 /* AllowNodeModulesRelativePaths */ | 10 | 2048 /* WriteClassExpressionAsTypeLiteral */ | 11 | 4096 /* UseTypeOfFunction */ | 12 | 8 /* UseStructuralFallback */ | 13 | -------------------------------------------------------------------------------- /packages/kubernetes/kafka-ephemeral.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kafka.strimzi.io/v1beta1 2 | kind: Kafka 3 | metadata: 4 | name: kafka-cluster 5 | spec: 6 | kafka: 7 | version: 2.5.0 8 | replicas: 3 9 | listeners: 10 | plain: {} 11 | tls: {} 12 | config: 13 | offsets.topic.replication.factor: 3 14 | transaction.state.log.replication.factor: 3 15 | transaction.state.log.min.isr: 2 16 | log.message.format.version: "2.5" 17 | storage: 18 | type: ephemeral 19 | zookeeper: 20 | replicas: 3 21 | storage: 22 | type: ephemeral 23 | entityOperator: 24 | topicOperator: {} 25 | userOperator: {} 26 | -------------------------------------------------------------------------------- /packages/schemas/load-proto-service.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as protoLoader from '@grpc/proto-loader'; 3 | import * as grpc from '@grpc/grpc-js'; 4 | 5 | import {SchemaService} from './schema.service'; 6 | 7 | const SCHEMA_PACKAGE_PATH = __dirname; 8 | 9 | export const loadProtoService = (service: SchemaService) => { 10 | const protoPath = path.join(SCHEMA_PACKAGE_PATH, service.package, service.proto); 11 | const packageDefinition = protoLoader.loadSync(protoPath); 12 | const grpcObject = grpc.loadPackageDefinition(packageDefinition); 13 | const pkg = grpcObject[service.package]; 14 | return (pkg as any)[service.service]; 15 | }; 16 | -------------------------------------------------------------------------------- /services/idea/idea-renamed.ts: -------------------------------------------------------------------------------- 1 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 2 | import {IdeaEventNames} from '@centsideas/enums'; 3 | import {IdeaModels} from '@centsideas/models'; 4 | 5 | import {IdeaTitle} from './idea-title'; 6 | 7 | @DomainEvent(IdeaEventNames.Renamed) 8 | export class IdeaRenamed implements IDomainEvent { 9 | constructor(public readonly title: IdeaTitle) {} 10 | 11 | serialize(): IdeaModels.IdeaRenamedData { 12 | return {title: this.title.toString()}; 13 | } 14 | 15 | static deserialize({title}: IdeaModels.IdeaRenamedData) { 16 | return new IdeaRenamed(IdeaTitle.fromString(title)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /services/idea/idea-tags-added.ts: -------------------------------------------------------------------------------- 1 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 2 | import {IdeaEventNames} from '@centsideas/enums'; 3 | import {IdeaModels} from '@centsideas/models'; 4 | 5 | import {IdeaTags} from './idea-tags'; 6 | 7 | @DomainEvent(IdeaEventNames.TagsAdded) 8 | export class IdeaTagsAdded implements IDomainEvent { 9 | constructor(public readonly tags: IdeaTags) {} 10 | 11 | serialize(): IdeaModels.IdeaTagsAddedData { 12 | return {tags: this.tags.toArray()}; 13 | } 14 | 15 | static deserialize({tags}: IdeaModels.IdeaTagsAddedData) { 16 | return new IdeaTagsAdded(IdeaTags.fromArray(tags)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /services/idea/idea-title.ts: -------------------------------------------------------------------------------- 1 | import * as sanitize from 'sanitize-html'; 2 | 3 | import {IdeaTitleLength} from '@centsideas/enums'; 4 | 5 | import * as Errors from './idea.errors'; 6 | 7 | export class IdeaTitle { 8 | private constructor(private title: string) { 9 | this.title = sanitize(this.title); 10 | if (this.title.length > IdeaTitleLength.Max) throw new Errors.IdeaTitleTooLong(title); 11 | if (this.title.length < IdeaTitleLength.Min) throw new Errors.IdeaTitleTooShort(title); 12 | } 13 | 14 | static fromString(title: string) { 15 | return new IdeaTitle(title); 16 | } 17 | 18 | toString() { 19 | return this.title; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /misc/patches/jest-haste-map+26.3.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/jest-haste-map/build/crawlers/node.js b/node_modules/jest-haste-map/build/crawlers/node.js 2 | index e952733..9310716 100644 3 | --- a/node_modules/jest-haste-map/build/crawlers/node.js 4 | +++ b/node_modules/jest-haste-map/build/crawlers/node.js 5 | @@ -217,7 +217,11 @@ function find(roots, extensions, ignore, callback) { 6 | 7 | function findNative(roots, extensions, ignore, callback) { 8 | const args = Array.from(roots); 9 | + args.push('('); 10 | args.push('-type', 'f'); 11 | + args.push('-o'); 12 | + args.push('-type', 'l'); 13 | + args.push(')'); 14 | 15 | if (extensions.length) { 16 | args.push('('); 17 | -------------------------------------------------------------------------------- /services/user/user-renamed.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {UserEventNames} from '@centsideas/enums'; 3 | import {UserModels} from '@centsideas/models'; 4 | import {Username} from '@centsideas/types'; 5 | 6 | @DomainEvent(UserEventNames.Renamed) 7 | export class UserRenamed implements IDomainEvent { 8 | constructor(public readonly username: Username) {} 9 | 10 | serialize(): UserModels.UserRenamedData { 11 | return { 12 | username: this.username.toString(), 13 | }; 14 | } 15 | 16 | static deserialize({username}: UserModels.UserRenamedData) { 17 | return new UserRenamed(Username.fromString(username)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /services/idea/idea-tags-removed.ts: -------------------------------------------------------------------------------- 1 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 2 | import {IdeaEventNames} from '@centsideas/enums'; 3 | import {IdeaModels} from '@centsideas/models'; 4 | 5 | import {IdeaTags} from './idea-tags'; 6 | 7 | @DomainEvent(IdeaEventNames.TagsRemoved) 8 | export class IdeaTagsRemoved implements IDomainEvent { 9 | constructor(public readonly tags: IdeaTags) {} 10 | 11 | serialize(): IdeaModels.IdeaTagsRemovedData { 12 | return { 13 | tags: this.tags.toArray(), 14 | }; 15 | } 16 | 17 | static deserialize({tags}: IdeaModels.IdeaTagsRemovedData) { 18 | return new IdeaTagsRemoved(IdeaTags.fromArray(tags)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/schemas/idea/idea-read.schema.ts: -------------------------------------------------------------------------------- 1 | import {IdeaModels} from '@centsideas/models'; 2 | 3 | export interface GetBydId { 4 | id: string; 5 | userId?: string; 6 | } 7 | 8 | export interface GetUnpublished { 9 | userId: string; 10 | } 11 | 12 | export interface GetAllByUserId { 13 | userId: string; 14 | privates: boolean; 15 | } 16 | 17 | export interface Service { 18 | getById: (payload: GetBydId) => Promise; 19 | getAll: (payload: void) => Promise<{ideas: IdeaModels.IdeaModel[]}>; 20 | getAllByUserId: (payload: GetAllByUserId) => Promise<{ideas: IdeaModels.IdeaModel[]}>; 21 | getUnpublished: (payload: GetUnpublished) => Promise; 22 | } 23 | -------------------------------------------------------------------------------- /services/idea/idea-deleted.ts: -------------------------------------------------------------------------------- 1 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 2 | import {Timestamp} from '@centsideas/types'; 3 | import {IdeaEventNames} from '@centsideas/enums'; 4 | import {IdeaModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(IdeaEventNames.Deleted) 7 | export class IdeaDeleted implements IDomainEvent { 8 | constructor(public readonly deletedAt: Timestamp) {} 9 | 10 | serialize(): IdeaModels.IdeaDeletedData { 11 | return { 12 | deletedAt: this.deletedAt.toString(), 13 | }; 14 | } 15 | 16 | static deserialize({deletedAt}: IdeaModels.IdeaDeletedData) { 17 | return new IdeaDeleted(Timestamp.fromString(deletedAt)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /services/review/review-score-changed.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {ReviewEventNames} from '@centsideas/enums'; 3 | import {ReviewModels} from '@centsideas/models'; 4 | 5 | import {ReviewScore} from './review-score'; 6 | 7 | @DomainEvent(ReviewEventNames.ScoreChanged) 8 | export class ReviewScoreChanged implements IDomainEvent { 9 | constructor(public readonly score: ReviewScore) {} 10 | 11 | serialize(): ReviewModels.ScoreChangedData { 12 | return {score: this.score.toObject()}; 13 | } 14 | 15 | static deserialize({score}: ReviewModels.ScoreChangedData) { 16 | return new ReviewScoreChanged(ReviewScore.fromObject(score)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/rpc/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | 3 | package(default_visibility = ["//visibility:public"]) 4 | 5 | ts_library( 6 | name = "rpc", 7 | srcs = glob( 8 | include = ["**/*.ts"], 9 | exclude = ["testing/**"], 10 | ), 11 | module_name = "@centsideas/rpc", 12 | deps = [ 13 | "//packages/enums", 14 | "//packages/models", 15 | "//packages/schemas", 16 | "//packages/types", 17 | "//packages/utils", 18 | "@npm//@grpc/grpc-js", 19 | "@npm//@grpc/proto-loader", 20 | "@npm//@types/async-retry", 21 | "@npm//async-retry", 22 | "@npm//inversify", 23 | ], 24 | ) 25 | -------------------------------------------------------------------------------- /services/review/review-published.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {ReviewEventNames} from '@centsideas/enums'; 3 | import {Timestamp} from '@centsideas/types'; 4 | import {ReviewModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(ReviewEventNames.Published) 7 | export class ReviewPublished implements IDomainEvent { 8 | constructor(public readonly publishedAt: Timestamp) {} 9 | 10 | serialize(): ReviewModels.PublishedData { 11 | return {publishedAt: this.publishedAt.toString()}; 12 | } 13 | 14 | static deserialize({publishedAt}: ReviewModels.PublishedData) { 15 | return new ReviewPublished(Timestamp.fromString(publishedAt)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/types/tokens/user-deletion-token.ts: -------------------------------------------------------------------------------- 1 | import {UserId} from '../identifiers'; 2 | import {Token} from './token'; 3 | 4 | interface UserDeletionTokenPayload { 5 | userId: string; 6 | } 7 | 8 | export class UserDeletionToken extends Token { 9 | constructor(public readonly userId: UserId) { 10 | super(); 11 | } 12 | 13 | static fromString(tokenString: string, secret: string) { 14 | const decoded = Token.decode(tokenString, secret); 15 | return new UserDeletionToken(UserId.fromString(decoded.userId)); 16 | } 17 | 18 | protected get serializedPayload() { 19 | return { 20 | userId: this.userId.toString(), 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /services/idea/idea-published.ts: -------------------------------------------------------------------------------- 1 | import {Timestamp} from '@centsideas/types'; 2 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 3 | import {IdeaEventNames} from '@centsideas/enums'; 4 | import {IdeaModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(IdeaEventNames.Published) 7 | export class IdeaPublished implements IDomainEvent { 8 | constructor(public readonly publishedAt: Timestamp) {} 9 | 10 | serialize(): IdeaModels.IdeaPublishedData { 11 | return { 12 | publishedAt: this.publishedAt.toString(), 13 | }; 14 | } 15 | 16 | static deserialize({publishedAt}: IdeaModels.IdeaPublishedData) { 17 | return new IdeaPublished(Timestamp.fromString(publishedAt)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /services/user-read/user-read.errors.ts: -------------------------------------------------------------------------------- 1 | import {Exception} from '@centsideas/utils'; 2 | import {RpcStatus, UserErrorNames} from '@centsideas/enums'; 3 | import {UserId, Email} from '@centsideas/types'; 4 | 5 | export class UserNotFound extends Exception { 6 | code = RpcStatus.NOT_FOUND; 7 | name = UserErrorNames.NotFound; 8 | 9 | constructor(id?: UserId) { 10 | super(`User with id ${id ? id.toString() : ''} wasn't found`); 11 | } 12 | } 13 | 14 | export class UserWithEmailNotFound extends Exception { 15 | code = RpcStatus.NOT_FOUND; 16 | name = UserErrorNames.NotFound; 17 | 18 | constructor(email: Email) { 19 | super(`User with email ${email.toString()} wasn't found`); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /services/review/review-content-edited.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {ReviewEventNames} from '@centsideas/enums'; 3 | import {ReviewModels} from '@centsideas/models'; 4 | 5 | import {ReviewContent} from './review-content'; 6 | 7 | @DomainEvent(ReviewEventNames.ContentEdited) 8 | export class ReviewContentEdited implements IDomainEvent { 9 | constructor(public readonly content: ReviewContent) {} 10 | 11 | serialize(): ReviewModels.ContentEditedData { 12 | return {content: this.content.toString()}; 13 | } 14 | 15 | static deserialize({content}: ReviewModels.ContentEditedData) { 16 | return new ReviewContentEdited(ReviewContent.fromString(content)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /services/user/user-deletion-confirmed.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {UserEventNames} from '@centsideas/enums'; 3 | import {UserModels} from '@centsideas/models'; 4 | import {Timestamp} from '@centsideas/types'; 5 | 6 | @DomainEvent(UserEventNames.DeletionConfirmed) 7 | export class UserDeletionConfirmed implements IDomainEvent { 8 | constructor(public readonly deletedAt: Timestamp) {} 9 | 10 | serialize(): UserModels.DeletionConfirmedData { 11 | return {deletedAt: this.deletedAt.toString()}; 12 | } 13 | 14 | static deserialize({deletedAt}: UserModels.DeletionConfirmedData) { 15 | return new UserDeletionConfirmed(Timestamp.fromString(deletedAt)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /services/authentication/signed-out.ts: -------------------------------------------------------------------------------- 1 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 2 | import {AuthenticationEventNames} from '@centsideas/enums'; 3 | import {Timestamp} from '@centsideas/types'; 4 | import {SessionModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(AuthenticationEventNames.SignedOut) 7 | export class SignedOut implements IDomainEvent { 8 | constructor(public readonly signedOutAt: Timestamp) {} 9 | 10 | serialize(): SessionModels.SignedOutData { 11 | return { 12 | signedOutAt: this.signedOutAt.toString(), 13 | }; 14 | } 15 | 16 | static deserialize({signedOutAt}: SessionModels.SignedOutData) { 17 | return new SignedOut(Timestamp.fromString(signedOutAt)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /services/user/private-user-deleted.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {PrivateUserEventNames} from '@centsideas/enums'; 3 | import {Timestamp} from '@centsideas/types'; 4 | import {UserModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(PrivateUserEventNames.Deleted) 7 | export class PrivateUserDeleted implements IDomainEvent { 8 | constructor(public readonly deletedAt: Timestamp) {} 9 | 10 | serialize(): UserModels.PrivateUserDeletedData { 11 | return { 12 | deletedAt: this.deletedAt.toString(), 13 | }; 14 | } 15 | 16 | static deserialize({deletedAt}: UserModels.PrivateUserDeletedData) { 17 | return new PrivateUserDeleted(Timestamp.fromString(deletedAt)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /services/user/email-change-requested.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {PrivateUserEventNames} from '@centsideas/enums'; 3 | import {Email} from '@centsideas/types'; 4 | import {UserModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(PrivateUserEventNames.EmailChangeRequested) 7 | export class EmailChangeRequested implements IDomainEvent { 8 | constructor(public readonly newEmail: Email) {} 9 | 10 | serialize(): UserModels.EmailChangeRequestedData { 11 | return { 12 | newEmail: this.newEmail.toString(), 13 | }; 14 | } 15 | 16 | static deserialize({newEmail}: UserModels.EmailChangeRequestedData) { 17 | return new EmailChangeRequested(Email.fromString(newEmail)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/rpc/rpc-status.ts: -------------------------------------------------------------------------------- 1 | import * as grpc from '@grpc/grpc-js'; 2 | 3 | export const RpcStatusHttpMap: Record = { 4 | [grpc.status.OK]: 200, 5 | [grpc.status.INVALID_ARGUMENT]: 400, 6 | [grpc.status.FAILED_PRECONDITION]: 400, 7 | [grpc.status.OUT_OF_RANGE]: 400, 8 | [grpc.status.UNAUTHENTICATED]: 401, 9 | [grpc.status.PERMISSION_DENIED]: 403, 10 | [grpc.status.NOT_FOUND]: 404, 11 | [grpc.status.ABORTED]: 409, 12 | [grpc.status.ALREADY_EXISTS]: 409, 13 | [grpc.status.RESOURCE_EXHAUSTED]: 429, 14 | [grpc.status.CANCELLED]: 499, 15 | [grpc.status.DATA_LOSS]: 500, 16 | [grpc.status.UNKNOWN]: 500, 17 | [grpc.status.INTERNAL]: 500, 18 | [grpc.status.UNAVAILABLE]: 503, 19 | [grpc.status.DEADLINE_EXCEEDED]: 504, 20 | }; 21 | -------------------------------------------------------------------------------- /services/user/user-deletion-requested.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {UserEventNames} from '@centsideas/enums'; 3 | import {UserModels} from '@centsideas/models'; 4 | import {Timestamp} from '@centsideas/types'; 5 | 6 | @DomainEvent(UserEventNames.DeletionRequested) 7 | export class UserDeletionRequested implements IDomainEvent { 8 | constructor(public readonly requestedAt: Timestamp) {} 9 | 10 | serialize(): UserModels.DeletionRequestedData { 11 | return { 12 | requestedAt: this.requestedAt.toString(), 13 | }; 14 | } 15 | 16 | static deserialize({requestedAt}: UserModels.DeletionRequestedData) { 17 | return new UserDeletionRequested(Timestamp.fromString(requestedAt)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/event-sourcing/in-memory-snapshot-store.ts: -------------------------------------------------------------------------------- 1 | import {injectable, interfaces} from 'inversify'; 2 | 3 | import {Id} from '@centsideas/types'; 4 | 5 | import {SnapshotStore} from './snapshot-store'; 6 | import {PersistedSnapshot} from './snapshot'; 7 | 8 | @injectable() 9 | export class InMemorySnapshotStore implements SnapshotStore { 10 | private snapshots: Record = {}; 11 | 12 | async store(snapshot: PersistedSnapshot) { 13 | this.snapshots[snapshot.aggregateId.toString()] = snapshot; 14 | } 15 | 16 | async get(id: Id) { 17 | return this.snapshots[id.toString()]; 18 | } 19 | } 20 | 21 | export const inMemorySnapshotStoreFactory = (context: interfaces.Context) => () => 22 | context.container.get(InMemorySnapshotStore); 23 | -------------------------------------------------------------------------------- /packages/schemas/authentication/index.ts: -------------------------------------------------------------------------------- 1 | import {EventTopics} from '@centsideas/enums'; 2 | 3 | import {SchemaService} from '../schema.service'; 4 | import {SchemaMessage} from '../schema-message'; 5 | import {SerializableMessage} from '../serializable-message'; 6 | 7 | export const AuthenticationCommandsService: SchemaService = { 8 | proto: 'authentication-commands.proto', 9 | package: 'authentication', 10 | service: 'AuthenticationCommands', 11 | }; 12 | 13 | @SerializableMessage(EventTopics.Session) 14 | export class AuthenticationEventMessage extends SchemaMessage { 15 | name = 'AuthenticationEvent'; 16 | package = 'authentication'; 17 | proto = 'authentication-events.proto'; 18 | } 19 | 20 | export * as AuthenticationCommands from './authentication-commands.schema'; 21 | -------------------------------------------------------------------------------- /services/mailing/email.service.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import * as sgMail from '@sendgrid/mail'; 3 | 4 | import {SecretsConfig} from '@centsideas/config'; 5 | 6 | import {MailingConfig} from './mailing.config'; 7 | 8 | @injectable() 9 | export class EmailService { 10 | private readonly from = this.config.get('mailing.from'); 11 | 12 | constructor(private secretsConfig: SecretsConfig, private config: MailingConfig) { 13 | sgMail.setApiKey(this.secretsConfig.get('secrets.sendgrid.api')); 14 | } 15 | 16 | sendMail( 17 | to: string, 18 | {subject, text, html}: {subject: string; text: string; html: string}, 19 | ): Promise { 20 | const message = {to, from: this.from, subject, text, html}; 21 | return sgMail.send(message); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /services/authentication/refresh-token.ts: -------------------------------------------------------------------------------- 1 | import {Token, UserId, SessionId} from '@centsideas/types'; 2 | 3 | interface RefreshTokenData { 4 | userId: string; 5 | sessionId: string; 6 | } 7 | 8 | export class RefreshToken extends Token { 9 | constructor(public readonly sessionId: SessionId, public readonly userId: UserId) { 10 | super(); 11 | } 12 | 13 | static fromString(token: string, secret: string) { 14 | const decoded = Token.decode(token, secret); 15 | return new RefreshToken( 16 | SessionId.fromString(decoded.sessionId), 17 | UserId.fromString(decoded.userId), 18 | ); 19 | } 20 | 21 | get serializedPayload() { 22 | return {sessionId: this.sessionId.toString(), userId: this.userId.toString()}; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /services/idea/idea-description-edited.ts: -------------------------------------------------------------------------------- 1 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 2 | import {IdeaEventNames} from '@centsideas/enums'; 3 | import {IdeaModels} from '@centsideas/models'; 4 | 5 | import {IdeaDescription} from './idea-description'; 6 | 7 | @DomainEvent(IdeaEventNames.DescriptionEdited) 8 | export class IdeaDescriptionEdited implements IDomainEvent { 9 | constructor(public readonly description: IdeaDescription) {} 10 | 11 | serialize(): IdeaModels.IdeaDescriptionEditedData { 12 | return { 13 | description: this.description.toString(), 14 | }; 15 | } 16 | 17 | static deserialize({description}: IdeaModels.IdeaDescriptionEditedData) { 18 | return new IdeaDescriptionEdited(IdeaDescription.fromString(description)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /services/review/review.rest: -------------------------------------------------------------------------------- 1 | @ideaId = JAJf8zCUd 2 | @reviewId = {{create.response.body.id}} 3 | 4 | ### 5 | 6 | # @name create 7 | POST {{apiUrl}}/review 8 | content-type: application/json 9 | Authorization: Bearer {{userId}} 10 | 11 | {"ideaId":"{{ideaId}}"} 12 | 13 | ### 14 | 15 | PUT {{apiUrl}}/review/{{reviewId}}/content 16 | content-type: application/json 17 | Authorization: Bearer {{userId}} 18 | 19 | {"content":"This idea is awesome!"} 20 | 21 | ### 22 | 23 | PUT {{apiUrl}}/review/{{reviewId}}/score 24 | content-type: application/json 25 | Authorization: Bearer {{userId}} 26 | 27 | { 28 | "control": 5, 29 | "entry": 4, 30 | "need": 3, 31 | "time": 2, 32 | "scale": 1 33 | } 34 | 35 | ### 36 | 37 | PUT {{apiUrl}}/review/{{reviewId}}/publish 38 | Authorization: Bearer {{userId}} -------------------------------------------------------------------------------- /packages/event-sourcing/replay-version-mismatch.ts: -------------------------------------------------------------------------------- 1 | import {Exception} from '@centsideas/utils'; 2 | import {RpcStatus, EventSourcingErrorNames} from '@centsideas/enums'; 3 | import {PersistedEvent} from '@centsideas/models'; 4 | 5 | import {StreamVersion} from './stream-version'; 6 | 7 | export class ReplayVersionMismatch extends Exception { 8 | code = RpcStatus.INTERNAL; 9 | name = EventSourcingErrorNames.ReplayVersionMismatch; 10 | 11 | constructor(event: PersistedEvent, aggregateVersion: StreamVersion) { 12 | super( 13 | `Version mismatch while replaying event ${event.id} of aggregate` + 14 | `with id ${event.streamId}. Aggregate version was at ` + 15 | `${aggregateVersion.toNumber()} and event version is ` + 16 | `${event.version}`, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/schemas/review/review-read.schema.ts: -------------------------------------------------------------------------------- 1 | import {ReviewModels} from '@centsideas/models'; 2 | 3 | export interface GetByIdeaId { 4 | ideaId: string; 5 | auid?: string; 6 | } 7 | 8 | export interface GetByAuthor { 9 | authorId: string; 10 | auid?: string; 11 | } 12 | 13 | export interface GetByAuthorAndIdea { 14 | ideaId: string; 15 | authorId: string; 16 | auid?: string; 17 | } 18 | 19 | export interface Service { 20 | getByIdeaId: (payload: GetByIdeaId) => Promise<{reviews: ReviewModels.ReviewModel[]}>; 21 | getByAuthorAndIdea: (payload: GetByAuthorAndIdea) => Promise; 22 | getByAuthor: (payload: GetByAuthor) => Promise<{reviews: ReviewModels.ReviewModel[]}>; 23 | getAll: (payload: void) => Promise<{reviews: ReviewModels.ReviewModel[]}>; 24 | } 25 | -------------------------------------------------------------------------------- /services/review/review-content.ts: -------------------------------------------------------------------------------- 1 | import * as sanitize from 'sanitize-html'; 2 | 3 | import {ReviewContentLength} from '@centsideas/enums'; 4 | 5 | import * as Errors from './review.errors'; 6 | 7 | export class ReviewContent { 8 | private constructor(private content: string) { 9 | this.content = sanitize(this.content); 10 | if (this.content.length > ReviewContentLength.Max) throw new Errors.ReviewTooLong(content); 11 | if (this.content.length < ReviewContentLength.Min) throw new Errors.ReviewTooShort(content); 12 | } 13 | 14 | static fromString(content: string) { 15 | return new ReviewContent(content); 16 | } 17 | 18 | toString() { 19 | return this.content; 20 | } 21 | 22 | equals(other: ReviewContent) { 23 | return this.toString() === other.toString(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /misc/assets/progress.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /services/user/private-user-created.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {PrivateUserEventNames} from '@centsideas/enums'; 3 | import {UserId, Email} from '@centsideas/types'; 4 | import {UserModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(PrivateUserEventNames.Created) 7 | export class PrivateUserCreated implements IDomainEvent { 8 | constructor(public readonly id: UserId, public readonly email: Email) {} 9 | 10 | serialize(): UserModels.PrivateUserCreatedData { 11 | return { 12 | id: this.id.toString(), 13 | email: this.email.toString(), 14 | }; 15 | } 16 | 17 | static deserialize({id, email}: UserModels.PrivateUserCreatedData) { 18 | return new PrivateUserCreated(UserId.fromString(id), Email.fromString(email)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/schemas/event-message-serialization.ts: -------------------------------------------------------------------------------- 1 | import {PersistedEvent} from '@centsideas/models'; 2 | import {EventTopics} from '@centsideas/enums'; 3 | import {EventName} from '@centsideas/types'; 4 | 5 | import {SchemaMessage} from './schema-message'; 6 | import {serializableMessageMap} from './serializable-message'; 7 | 8 | export function serializeEventMessage(event: PersistedEvent, topic: EventTopics) { 9 | const message = getMessage(topic); 10 | return message.encode(event); 11 | } 12 | 13 | export function deserializeEventMessage(buffer: Buffer, event: EventName): PersistedEvent { 14 | const message = getMessage(event.getTopic()); 15 | return message.decode(buffer, event); 16 | } 17 | 18 | function getMessage(topic: EventTopics): SchemaMessage { 19 | return serializableMessageMap.get(topic); 20 | } 21 | -------------------------------------------------------------------------------- /services/idea/idea-description.ts: -------------------------------------------------------------------------------- 1 | import * as sanitize from 'sanitize-html'; 2 | 3 | import {IdeaDescriptionLength} from '@centsideas/enums'; 4 | 5 | import * as Errors from './idea.errors'; 6 | 7 | export class IdeaDescription { 8 | static readonly maxLength = IdeaDescriptionLength.Max; 9 | 10 | private constructor(private readonly description: string) { 11 | this.description = sanitize(this.description); 12 | if (this.description.length > IdeaDescription.maxLength) 13 | throw new Errors.IdeaDescriptionTooLong(description); 14 | } 15 | 16 | static empty() { 17 | return new IdeaDescription(''); 18 | } 19 | 20 | static fromString(description: string) { 21 | return new IdeaDescription(description); 22 | } 23 | 24 | toString() { 25 | return this.description; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/event-sourcing/domain-event.ts: -------------------------------------------------------------------------------- 1 | export interface IDomainEvent { 2 | serialize(): object; 3 | } 4 | 5 | export type DomainEventInstance = T; 6 | 7 | export const EVENT_NAME_METADATA = '__eventName__'; 8 | 9 | export const eventDeserializerMap = new Map(); 10 | 11 | export const DomainEvent = (name: string) => { 12 | // tslint:disable-next-line:ban-types 13 | return function DomainEventDecorator(target: Function) { 14 | Reflect.defineMetadata(EVENT_NAME_METADATA, name, target.prototype); 15 | const deserialize = (target as any).deserialize; 16 | if (!deserialize) 17 | throw new Error( 18 | `DomainEvent ${name} does not have a "static deserialize" method. Please implement it!`, 19 | ); 20 | eventDeserializerMap.set(name, deserialize); 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /services/user/user.rest: -------------------------------------------------------------------------------- 1 | @username = flolu 2 | @newEmail = ___ 3 | @deletionToken = ___ 4 | @changeEmailToken = ___ 5 | 6 | ### 7 | 8 | PUT {{apiUrl}}/user/rename 9 | content-type: application/json 10 | Authorization: Bearer {{userId}} 11 | 12 | {"username":"{{username}}"} 13 | 14 | ### 15 | 16 | POST {{apiUrl}}/user/requestDeletion 17 | Authorization: Bearer {{userId}} 18 | 19 | ### 20 | 21 | DELETE {{apiUrl}}/user 22 | Authorization: Bearer {{userId}} 23 | content-type: application/json 24 | 25 | {"token":"{{deletionToken}}"} 26 | 27 | ### 28 | 29 | POST {{apiUrl}}/user/email/requestChange 30 | content-type: application/json 31 | Authorization: Bearer {{userId}} 32 | 33 | {"email":"{{newEmail}}"} 34 | 35 | ### 36 | 37 | PUT {{apiUrl}}/user/email 38 | content-type: application/json 39 | 40 | {"token":"{{changeEmailToken}}"} -------------------------------------------------------------------------------- /packages/event-sourcing/index.ts: -------------------------------------------------------------------------------- 1 | export * from './aggregate'; 2 | export * from './event-id'; 3 | export * from './domain-event'; 4 | export * from './stream-event'; 5 | export * from './stream-version'; 6 | export * from './apply-event'; 7 | export * from './constants'; 8 | 9 | export * from './event-store'; 10 | export * from './in-memory-event-store'; 11 | export * from './mongo-event-store'; 12 | export * from './optimistic-concurrency-issue'; 13 | 14 | export * from './snapshot'; 15 | export * from './mongo-snapshot-store'; 16 | export * from './in-memory-snapshot-store'; 17 | 18 | export * from './projector'; 19 | export * from './in-memory-projector'; 20 | export * from './mongo-projector'; 21 | export * from './elastic-projector'; 22 | export * from './event-bus'; 23 | export * from './events-handler'; 24 | export * from './event-handler'; 25 | -------------------------------------------------------------------------------- /packages/types/tokens/access-token.ts: -------------------------------------------------------------------------------- 1 | import {UserId, SessionId} from '@centsideas/types'; 2 | 3 | import {Token} from './token'; 4 | 5 | interface SerializedAccessToken { 6 | userId: string; 7 | sessionId: string; 8 | } 9 | 10 | export class AccessToken extends Token { 11 | constructor(public readonly sessionId: SessionId, public readonly userId: UserId) { 12 | super(); 13 | } 14 | 15 | static fromString(token: string, secret: string) { 16 | const decoded = Token.decode(token, secret); 17 | return new AccessToken( 18 | SessionId.fromString(decoded.sessionId), 19 | UserId.fromString(decoded.userId), 20 | ); 21 | } 22 | 23 | protected get serializedPayload() { 24 | return {sessionId: this.sessionId.toString(), userId: this.userId.toString()}; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/kubernetes/kafka-persistent.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kafka.strimzi.io/v1beta1 2 | kind: Kafka 3 | metadata: 4 | name: kafka-cluster 5 | spec: 6 | kafka: 7 | version: 2.5.0 8 | replicas: 3 9 | listeners: 10 | plain: {} 11 | tls: {} 12 | config: 13 | offsets.topic.replication.factor: 3 14 | transaction.state.log.replication.factor: 3 15 | transaction.state.log.min.isr: 2 16 | log.message.format.version: "2.5" 17 | storage: 18 | type: jbod 19 | volumes: 20 | - id: 0 21 | type: persistent-claim 22 | size: 10Gi 23 | deleteClaim: false 24 | zookeeper: 25 | replicas: 3 26 | storage: 27 | type: persistent-claim 28 | size: 10Gi 29 | deleteClaim: false 30 | entityOperator: 31 | topicOperator: {} 32 | userOperator: {} 33 | -------------------------------------------------------------------------------- /packages/angular-bazel/tools/pkg_pwa.bzl: -------------------------------------------------------------------------------- 1 | load("@build_bazel_rules_nodejs//:index.bzl", "pkg_web") 2 | load(":tools/ngsw_config.bzl", _ngsw_config = "ngsw_config") 3 | 4 | def pkg_pwa( 5 | name, 6 | srcs, 7 | index_html, 8 | ngsw_config, 9 | additional_root_paths = []): 10 | pkg_web( 11 | name = "%s_web" % name, 12 | srcs = srcs + ["@npm//:node_modules/@angular/service-worker/ngsw-worker.js", "@npm//:node_modules/zone.js/dist/zone.min.js"], 13 | additional_root_paths = additional_root_paths + ["npm/node_modules/@angular/service-worker"], 14 | visibility = ["//visibility:private"], 15 | ) 16 | 17 | _ngsw_config( 18 | name = name, 19 | src = ":%s_web" % name, 20 | config = ngsw_config, 21 | index_html = index_html, 22 | tags = ["app"], 23 | ) 24 | -------------------------------------------------------------------------------- /packages/models/review.models.ts: -------------------------------------------------------------------------------- 1 | export interface ReviewModel { 2 | id: string; 3 | authorUserId: string; 4 | receiverUserId: string; 5 | ideaId: string; 6 | content: string | undefined; 7 | score: Score | undefined; 8 | publishedAt: string | undefined; 9 | updatedAt: string; 10 | lastEventVersion: number; 11 | } 12 | 13 | export interface CreatedData { 14 | id: string; 15 | authorUserId: string; 16 | receiverUserId: string; 17 | ideaId: string; 18 | createdAt: string; 19 | } 20 | 21 | export interface ContentEditedData { 22 | content: string; 23 | } 24 | 25 | export interface ScoreChangedData { 26 | score: Score; 27 | } 28 | 29 | export interface PublishedData { 30 | publishedAt: string; 31 | } 32 | 33 | export interface Score { 34 | control: number; 35 | entry: number; 36 | need: number; 37 | time: number; 38 | scale: number; 39 | } 40 | -------------------------------------------------------------------------------- /packages/schemas/idea/index.ts: -------------------------------------------------------------------------------- 1 | import {EventTopics} from '@centsideas/enums'; 2 | 3 | import {SchemaService} from '../schema.service'; 4 | import {SchemaMessage} from '../schema-message'; 5 | import {SerializableMessage} from '../serializable-message'; 6 | 7 | export const IdeaCommandsService: SchemaService = { 8 | proto: 'idea-commands.proto', 9 | package: 'idea', 10 | service: 'IdeaCommands', 11 | }; 12 | 13 | export const IdeaReadService: SchemaService = { 14 | proto: 'idea-read.proto', 15 | package: 'idea', 16 | service: 'IdeaRead', 17 | }; 18 | 19 | @SerializableMessage(EventTopics.Idea) 20 | export class IdeaEventMessage extends SchemaMessage { 21 | name = 'IdeaEvent'; 22 | package = 'idea'; 23 | proto = 'idea-events.proto'; 24 | } 25 | 26 | export * as IdeaCommands from './idea-commands.schema'; 27 | export * as IdeaReadQueries from './idea-read.schema'; 28 | -------------------------------------------------------------------------------- /packages/schemas/user/user-read.schema.ts: -------------------------------------------------------------------------------- 1 | import {UserModels} from '@centsideas/models'; 2 | 3 | export interface GetMe { 4 | id: string; 5 | } 6 | 7 | export interface GetById { 8 | id: string; 9 | } 10 | 11 | export interface GetByEmail { 12 | email: string; 13 | } 14 | 15 | export interface GetByUsername { 16 | username: string; 17 | } 18 | 19 | export interface Service { 20 | getMe: ( 21 | payload: GetMe, 22 | ) => Promise<{public: UserModels.UserView; private: UserModels.PrivateUserView}>; 23 | getById: (payload: GetById) => Promise; 24 | getByEmail: (payload: GetByEmail) => Promise; 25 | getByUsername: (payload: GetByUsername) => Promise; 26 | getEmailById: (payload: GetById) => Promise<{email: string}>; 27 | getAll: (payload: void) => Promise<{users: UserModels.UserView[]}>; 28 | } 29 | -------------------------------------------------------------------------------- /packages/types/email.ts: -------------------------------------------------------------------------------- 1 | import * as sanitize from 'sanitize-html'; 2 | 3 | import {Exception} from '@centsideas/utils'; 4 | import {RpcStatus, GenericErrorNames} from '@centsideas/enums'; 5 | 6 | const EMAIL_REGEX = new RegExp(/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/); 7 | 8 | export class Email { 9 | private constructor(private email: string) { 10 | this.email = sanitize(this.email); 11 | if (!EMAIL_REGEX.test(this.email)) throw new InvalidEmail(this.email); 12 | } 13 | 14 | static fromString(email: string) { 15 | return new Email(email); 16 | } 17 | 18 | toString() { 19 | return this.email; 20 | } 21 | } 22 | 23 | class InvalidEmail extends Exception { 24 | code = RpcStatus.INVALID_ARGUMENT; 25 | name = GenericErrorNames.InvalidEmail; 26 | 27 | constructor(email: string) { 28 | super(`${email} is not a valid email adress`); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/schemas/review/index.ts: -------------------------------------------------------------------------------- 1 | import {EventTopics} from '@centsideas/enums'; 2 | 3 | import {SchemaService} from '../schema.service'; 4 | import {SerializableMessage} from '../serializable-message'; 5 | import {SchemaMessage} from '../schema-message'; 6 | 7 | export const ReviewCommandsService: SchemaService = { 8 | proto: 'review-commands.proto', 9 | package: 'review', 10 | service: 'ReviewCommands', 11 | }; 12 | 13 | export const ReviewReadService: SchemaService = { 14 | proto: 'review-read.proto', 15 | package: 'review', 16 | service: 'ReviewRead', 17 | }; 18 | 19 | @SerializableMessage(EventTopics.Review) 20 | export class ReviewEventMessage extends SchemaMessage { 21 | name = 'ReviewEvent'; 22 | package = 'review'; 23 | proto = 'review-events.proto'; 24 | } 25 | 26 | export * as ReviewCommands from './review-commands.schema'; 27 | export * as ReviewQueries from './review-read.schema'; 28 | -------------------------------------------------------------------------------- /services/idea/user-read.adapter.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject} from 'inversify'; 2 | 3 | import {RPC_CLIENT_FACTORY, RpcClientFactory, RpcClient} from '@centsideas/rpc'; 4 | import {UserReadService, UserReadQueries} from '@centsideas/schemas'; 5 | import {UserId} from '@centsideas/types'; 6 | 7 | import {IdeaConfig} from './idea.config'; 8 | 9 | @injectable() 10 | export class UserReadAdapter { 11 | private userReadRpc: RpcClient = this.rpcClientFactory({ 12 | host: this.config.get('user-read.rpc.host'), 13 | service: UserReadService, 14 | port: this.config.getNumber('user-read.rpc.port'), 15 | }); 16 | 17 | constructor( 18 | private config: IdeaConfig, 19 | @inject(RPC_CLIENT_FACTORY) private rpcClientFactory: RpcClientFactory, 20 | ) {} 21 | 22 | getUserById(user: UserId) { 23 | return this.userReadRpc.client.getById({id: user.toString()}); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/types/tokens/change-email-token.ts: -------------------------------------------------------------------------------- 1 | import {Token} from './token'; 2 | import {UserId} from '../identifiers'; 3 | import {Email} from '../email'; 4 | 5 | interface SerializedChangeEmailToken { 6 | userId: string; 7 | newEmail: string; 8 | } 9 | 10 | export class ChangeEmailToken extends Token { 11 | constructor(public readonly userId: UserId, public readonly newEmail: Email) { 12 | super(); 13 | } 14 | 15 | static fromString(tokenString: string, secret: string) { 16 | const decoded = Token.decode(tokenString, secret); 17 | return new ChangeEmailToken( 18 | UserId.fromString(decoded.userId), 19 | Email.fromString(decoded.newEmail), 20 | ); 21 | } 22 | 23 | protected get serializedPayload() { 24 | return { 25 | userId: this.userId.toString(), 26 | newEmail: this.newEmail.toString(), 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/types/tokens/token.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from 'jsonwebtoken'; 2 | 3 | import {Exception} from '@centsideas/utils'; 4 | import {RpcStatus, GenericErrorNames} from '@centsideas/enums'; 5 | 6 | export abstract class Token { 7 | static decode(token: string, secret: string) { 8 | try { 9 | return (jwt.verify(token, secret) as any) as T; 10 | } catch (error) { 11 | throw new InvalidToken(token); 12 | } 13 | } 14 | 15 | sign(secret: string, expiresInSeconds: number) { 16 | return jwt.sign(Object(this.serializedPayload), secret, {expiresIn: expiresInSeconds}); 17 | } 18 | 19 | protected abstract serializedPayload: T; 20 | } 21 | 22 | export class InvalidToken extends Exception { 23 | code = RpcStatus.INVALID_ARGUMENT; 24 | name = GenericErrorNames.InvalidToken; 25 | 26 | constructor(invalidToken: string) { 27 | super(`You provided an invalid token: ${invalidToken}`); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /services/mailing/user-read.adapter.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject} from 'inversify'; 2 | 3 | import {RPC_CLIENT_FACTORY, RpcClientFactory, RpcClient} from '@centsideas/rpc'; 4 | import {UserReadService, UserReadQueries} from '@centsideas/schemas'; 5 | import {UserId} from '@centsideas/types'; 6 | 7 | import {MailingConfig} from './mailing.config'; 8 | 9 | @injectable() 10 | export class UserReadAdapter { 11 | private userReadRpc: RpcClient = this.rpcClientFactory({ 12 | host: this.config.get('user-read.rpc.host'), 13 | service: UserReadService, 14 | port: this.config.getNumber('user-read.rpc.port'), 15 | }); 16 | 17 | constructor( 18 | private config: MailingConfig, 19 | @inject(RPC_CLIENT_FACTORY) private rpcClientFactory: RpcClientFactory, 20 | ) {} 21 | 22 | getEmailById(user: UserId) { 23 | return this.userReadRpc.client.getEmailById({id: user.toString()}); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/types/tokens/email-sign-in-token.ts: -------------------------------------------------------------------------------- 1 | import {Token} from './token'; 2 | import {SessionId} from '../identifiers'; 3 | import {Email} from '../email'; 4 | 5 | interface SerializedEmailSignInToken { 6 | sessionId: string; 7 | email: string; 8 | } 9 | 10 | export class EmailSignInToken extends Token { 11 | constructor(public readonly sessionId: SessionId, public readonly email: Email) { 12 | super(); 13 | } 14 | 15 | static fromString(tokenString: string, secret: string) { 16 | const decoded = Token.decode(tokenString, secret); 17 | return new EmailSignInToken( 18 | SessionId.fromString(decoded.sessionId), 19 | Email.fromString(decoded.email), 20 | ); 21 | } 22 | 23 | protected get serializedPayload() { 24 | return { 25 | sessionId: this.sessionId.toString(), 26 | email: this.email.toString(), 27 | }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/models/idea.models.ts: -------------------------------------------------------------------------------- 1 | export interface IdeaModel { 2 | id: string; 3 | userId: string; 4 | title: string | undefined; 5 | description: string | undefined; 6 | tags: string[]; 7 | createdAt: string; 8 | publishedAt: string | undefined; 9 | deletedAt: string | undefined; 10 | lastEventVersion: number; 11 | updatedAt: string; 12 | } 13 | 14 | export interface IdeaCreatedData { 15 | id: string; 16 | userId: string; 17 | createdAt: string; 18 | } 19 | 20 | export interface IdeaDeletedData { 21 | deletedAt: string; 22 | } 23 | 24 | export interface IdeaDescriptionEditedData { 25 | description: string; 26 | } 27 | 28 | export interface IdeaPublishedData { 29 | publishedAt: string; 30 | } 31 | 32 | export interface IdeaRenamedData { 33 | title: string; 34 | } 35 | 36 | export interface IdeaTagsAddedData { 37 | tags: string[]; 38 | } 39 | 40 | export interface IdeaTagsRemovedData { 41 | tags: string[]; 42 | } 43 | -------------------------------------------------------------------------------- /packages/schemas/user/user-commands.schema.ts: -------------------------------------------------------------------------------- 1 | import {GetEventsCommand} from '../common'; 2 | 3 | export interface RenameUser { 4 | userId: string; 5 | username: string; 6 | } 7 | 8 | export interface RequestDeletion { 9 | userId: string; 10 | } 11 | 12 | export interface ConfirmDeletion { 13 | token: string; 14 | } 15 | 16 | export interface RequestEmailChange { 17 | userId: string; 18 | newEmail: string; 19 | } 20 | 21 | export interface ConfirmEmailChange { 22 | token: string; 23 | } 24 | 25 | export interface Service { 26 | rename: (payload: RenameUser) => Promise; 27 | requestDeletion: (payload: RequestDeletion) => Promise; 28 | confirmDeletion: (payload: ConfirmDeletion) => Promise; 29 | requestEmailChange: (payload: RequestEmailChange) => Promise; 30 | confirmEmailChange: (payload: ConfirmEmailChange) => Promise; 31 | 32 | getEvents: GetEventsCommand; 33 | getPrivateEvents: GetEventsCommand; 34 | } 35 | -------------------------------------------------------------------------------- /packages/utils/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | load("@centsideas//packages/jest:jest.bzl", "ts_jest") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | ts_library( 7 | name = "utils", 8 | srcs = glob( 9 | include = ["**/*.ts"], 10 | exclude = [ 11 | "**/*.spec.ts", 12 | "test/**", 13 | ], 14 | ), 15 | module_name = "@centsideas/utils", 16 | deps = [ 17 | "//packages/config", 18 | "//packages/enums", 19 | "//packages/models", 20 | "@npm//@types/jsonwebtoken", 21 | "@npm//@types/node", 22 | "@npm//chalk", 23 | "@npm//inversify", 24 | "@npm//jsonwebtoken", 25 | "@npm//reflect-metadata", 26 | ], 27 | ) 28 | 29 | ts_jest( 30 | name = "test", 31 | srcs = glob(include = ["**/*.spec.ts"]), 32 | test_lib = "utils", 33 | tsconfig = "//:tsconfig.json", 34 | ) 35 | -------------------------------------------------------------------------------- /packages/utils/exception.ts: -------------------------------------------------------------------------------- 1 | import {RpcStatus, GenericErrorNames} from '@centsideas/enums'; 2 | 3 | // FIXME resolving the originator service would be nice 4 | export abstract class Exception extends Error { 5 | abstract code: RpcStatus; 6 | abstract name: string; 7 | 8 | public timestamp = new Date().toISOString(); 9 | 10 | constructor(public message: string, public details?: any) { 11 | super(message); 12 | } 13 | } 14 | 15 | export class UnexpectedException extends Exception { 16 | code = RpcStatus.UNKNOWN; 17 | name = GenericErrorNames.Unexpected; 18 | 19 | constructor(message?: string, details?: any) { 20 | super(message || 'Unexpected error occurred!', details); 21 | } 22 | } 23 | 24 | export class InvalidAuthToken extends Exception { 25 | code = RpcStatus.INVALID_ARGUMENT; 26 | name = GenericErrorNames.InvalidAuthToken; 27 | 28 | constructor(message: string, details?: any) { 29 | super(message, details); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/event-sourcing/optimistic-concurrency-issue.ts: -------------------------------------------------------------------------------- 1 | import {Exception} from '@centsideas/utils'; 2 | import {RpcStatus, EventSourcingErrorNames} from '@centsideas/enums'; 3 | 4 | /** 5 | * Before events are saved to event store they are validated 6 | * using the current state. 7 | * This error is thrown when the state of the aggragate 8 | * changed during validation 9 | * 10 | * More details here: 11 | * https://youtu.be/GzrZworHpIk?t=1028 12 | */ 13 | export class OptimisticConcurrencyIssue extends Exception { 14 | code = RpcStatus.ABORTED; 15 | name = EventSourcingErrorNames.OptimisticConcurrencyIssue; 16 | 17 | constructor( 18 | eventStore: string, 19 | streamId: string, 20 | lastVersion: number, 21 | bookmark: number, 22 | lastEventId: string, 23 | ) { 24 | super('Optimistic concurrency issue', { 25 | eventStore, 26 | streamId, 27 | lastVersion, 28 | bookmark, 29 | lastEventId, 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/schemas/idea/idea-read.proto: -------------------------------------------------------------------------------- 1 | import "google/protobuf/empty.proto"; 2 | 3 | syntax = "proto3"; 4 | 5 | package idea; 6 | 7 | service IdeaRead { 8 | rpc getById(GetIdeaById) returns (Idea); 9 | rpc getAll(google.protobuf.Empty) returns (Ideas); 10 | rpc getAllByUserId(GetAllByUserId) returns (Ideas); 11 | rpc getUnpublished(GetUnpublished) returns (Idea); 12 | } 13 | 14 | message GetIdeaById { 15 | string id = 1; 16 | optional string userId = 2; 17 | } 18 | 19 | message GetAllByUserId { 20 | string userId = 1; 21 | bool privates = 2; 22 | } 23 | 24 | message GetUnpublished { 25 | string userId = 1; 26 | } 27 | 28 | message Idea { 29 | string id = 1; 30 | string userId = 2; 31 | string title = 3; 32 | string description = 4; 33 | repeated string tags = 5; 34 | string createdAt = 6; 35 | string publishedAt = 7; 36 | string deletedAt = 8; 37 | string updatedAt = 9; 38 | } 39 | 40 | message Ideas { 41 | repeated Idea ideas = 1; 42 | } 43 | -------------------------------------------------------------------------------- /services/idea/idea-created.ts: -------------------------------------------------------------------------------- 1 | import {IDomainEvent, DomainEvent} from '@centsideas/event-sourcing'; 2 | import {IdeaId, UserId, Timestamp} from '@centsideas/types'; 3 | import {IdeaEventNames} from '@centsideas/enums'; 4 | import {IdeaModels} from '@centsideas/models'; 5 | 6 | @DomainEvent(IdeaEventNames.Created) 7 | export class IdeaCreated implements IDomainEvent { 8 | constructor( 9 | public readonly id: IdeaId, 10 | public readonly userId: UserId, 11 | public readonly createdAt: Timestamp, 12 | ) {} 13 | 14 | serialize(): IdeaModels.IdeaCreatedData { 15 | return { 16 | id: this.id.toString(), 17 | userId: this.userId.toString(), 18 | createdAt: this.createdAt.toString(), 19 | }; 20 | } 21 | 22 | static deserialize({id, userId, createdAt}: IdeaModels.IdeaCreatedData) { 23 | return new IdeaCreated( 24 | IdeaId.fromString(id), 25 | UserId.fromString(userId), 26 | Timestamp.fromString(createdAt), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/types/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | load("@centsideas//packages/jest:jest.bzl", "ts_jest") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | ts_library( 7 | name = "types", 8 | srcs = glob( 9 | include = ["**/*.ts"], 10 | exclude = ["**/*.spec.ts"], 11 | ), 12 | module_name = "@centsideas/types", 13 | deps = [ 14 | "//packages/enums", 15 | "//packages/utils", 16 | "@npm//@types/jsonwebtoken", 17 | "@npm//@types/sanitize-html", 18 | "@npm//@types/shortid", 19 | "@npm//@types/uuid", 20 | "@npm//jsonwebtoken", 21 | "@npm//sanitize-html", 22 | "@npm//shortid", 23 | "@npm//uuid", 24 | ], 25 | ) 26 | 27 | ts_jest( 28 | name = "test", 29 | srcs = glob(include = ["**/*.spec.ts"]), 30 | test_lib = "types", 31 | tsconfig = "//:tsconfig.json", 32 | deps = [ 33 | "@npm//reflect-metadata", 34 | ], 35 | ) 36 | -------------------------------------------------------------------------------- /services/user/user-created.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {UserEventNames} from '@centsideas/enums'; 3 | import {UserModels} from '@centsideas/models'; 4 | import {UserId, Timestamp, Username} from '@centsideas/types'; 5 | 6 | @DomainEvent(UserEventNames.Created) 7 | export class UserCreated implements IDomainEvent { 8 | constructor( 9 | public readonly id: UserId, 10 | public readonly username: Username, 11 | public readonly createdAt: Timestamp, 12 | ) {} 13 | 14 | serialize(): UserModels.UserCreatedData { 15 | return { 16 | id: this.id.toString(), 17 | username: this.username.toString(), 18 | createdAt: this.createdAt.toString(), 19 | }; 20 | } 21 | 22 | static deserialize({id, username, createdAt}: UserModels.UserCreatedData) { 23 | return new UserCreated( 24 | UserId.fromString(id), 25 | Username.fromString(username), 26 | Timestamp.fromString(createdAt), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/models/user.models.ts: -------------------------------------------------------------------------------- 1 | export interface PrivateUserCreatedData { 2 | id: string; 3 | email: string; 4 | } 5 | 6 | export interface PrivateUserDeletedData { 7 | deletedAt: string; 8 | } 9 | 10 | export interface EmailChangeRequestedData { 11 | newEmail: string; 12 | } 13 | 14 | export interface EmailChangeConfirmedData {} 15 | 16 | export interface UserCreatedData { 17 | id: string; 18 | username: string; 19 | createdAt: string; 20 | } 21 | 22 | export interface UserRenamedData { 23 | username: string; 24 | } 25 | 26 | export interface DeletionRequestedData { 27 | requestedAt: string; 28 | } 29 | 30 | export interface DeletionConfirmedData { 31 | deletedAt: string; 32 | } 33 | 34 | export interface UserView { 35 | id: string; 36 | username: string; 37 | createdAt: string; 38 | updatedAt: string; 39 | lastEventVersion: number; 40 | } 41 | 42 | export interface PrivateUserView { 43 | id: string; 44 | email: string; 45 | pendingEmail: string | undefined; 46 | lastEventVersion: number; 47 | } 48 | -------------------------------------------------------------------------------- /packages/schemas/review/review-commands.schema.ts: -------------------------------------------------------------------------------- 1 | import {PersistedEvent, ReviewModels} from '@centsideas/models'; 2 | 3 | import {GetEventsCommand} from '../common'; 4 | 5 | export interface Create { 6 | userId: string; 7 | ideaId: string; 8 | } 9 | 10 | export interface EditContent { 11 | id: string; 12 | userId: string; 13 | content: string; 14 | } 15 | 16 | export interface ChangeScore { 17 | id: string; 18 | userId: string; 19 | score: ReviewModels.Score; 20 | } 21 | 22 | export interface Publish { 23 | id: string; 24 | userId: string; 25 | } 26 | 27 | export interface GetByUserId { 28 | userId: string; 29 | } 30 | 31 | export interface Service { 32 | create: (payload: Create) => Promise<{id: string}>; 33 | editContent: (payload: EditContent) => Promise; 34 | changeScore: (payload: ChangeScore) => Promise; 35 | publish: (payload: Publish) => Promise; 36 | getEventsByUserId: (payload: GetByUserId) => Promise<{events: PersistedEvent[]}>; 37 | 38 | getEvents: GetEventsCommand; 39 | } 40 | -------------------------------------------------------------------------------- /services/mailing/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 3 | 4 | import 'reflect-metadata'; 5 | 6 | import {Logger} from '@centsideas/utils'; 7 | import {DI} from '@centsideas/dependency-injection'; 8 | import {SecretsConfig, GlobalConfig} from '@centsideas/config'; 9 | import {EventListener} from '@centsideas/event-sourcing'; 10 | import {RPC_CLIENT_FACTORY, rpcClientFactory, RpcClient} from '@centsideas/rpc'; 11 | 12 | import {MailingServer} from './mailing.server'; 13 | import {MailingConfig} from './mailing.config'; 14 | import {UserReadAdapter} from './user-read.adapter'; 15 | import {MailingService} from './mailing.service'; 16 | 17 | DI.registerProviders(MailingServer, UserReadAdapter, MailingService); 18 | DI.registerSingletons(Logger, MailingConfig, SecretsConfig, GlobalConfig); 19 | 20 | DI.registerProviders(EventListener, RpcClient); 21 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 22 | 23 | DI.bootstrap(MailingServer); 24 | -------------------------------------------------------------------------------- /services/admin/admin.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: admin-deployment 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: admin 9 | replicas: 1 10 | template: 11 | metadata: 12 | labels: 13 | app: admin 14 | spec: 15 | containers: 16 | - name: admin 17 | image: admin:placeholder_name 18 | imagePullPolicy: Always 19 | envFrom: 20 | - configMapRef: 21 | name: global-config 22 | readinessProbe: 23 | httpGet: 24 | path: / 25 | port: 3000 26 | initialDelaySeconds: 5 27 | periodSeconds: 5 28 | livenessProbe: 29 | httpGet: 30 | path: / 31 | port: 3000 32 | initialDelaySeconds: 15 33 | periodSeconds: 10 34 | resources: 35 | requests: 36 | memory: 10Mi 37 | cpu: 10m 38 | limits: 39 | memory: 500Mi 40 | cpu: 500m 41 | -------------------------------------------------------------------------------- /services/search/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 3 | 4 | import {DI} from '@centsideas/dependency-injection'; 5 | import {Logger} from '@centsideas/utils'; 6 | import {GlobalConfig} from '@centsideas/config'; 7 | import {EventListener} from '@centsideas/event-sourcing'; 8 | import { 9 | RpcClient, 10 | RpcServer, 11 | RPC_SERVER_FACTORY, 12 | rpcServerFactory, 13 | RPC_CLIENT_FACTORY, 14 | rpcClientFactory, 15 | } from '@centsideas/rpc'; 16 | 17 | import {SearchConfig} from './search.config'; 18 | import {SearchServer} from './search.server'; 19 | import {SearchProjector} from './search.projector'; 20 | 21 | DI.registerProviders(SearchServer, SearchProjector); 22 | DI.registerSingletons(Logger, SearchConfig, GlobalConfig); 23 | 24 | DI.registerProviders(EventListener); 25 | DI.registerProviders(RpcClient, RpcServer); 26 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 27 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 28 | 29 | DI.bootstrap(SearchServer); 30 | -------------------------------------------------------------------------------- /packages/event-sourcing/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | load("@centsideas//packages/jest:jest.bzl", "ts_jest") 3 | 4 | package(default_visibility = ["//visibility:public"]) 5 | 6 | ts_library( 7 | name = "event-sourcing", 8 | srcs = glob( 9 | include = ["**/*.ts"], 10 | exclude = ["**/*.spec.ts"], 11 | ), 12 | module_name = "@centsideas/event-sourcing", 13 | deps = [ 14 | "//packages/config", 15 | "//packages/enums", 16 | "//packages/models", 17 | "//packages/schemas", 18 | "//packages/types", 19 | "//packages/utils", 20 | "@npm//@elastic/elasticsearch", 21 | "@npm//@types/async-retry", 22 | "@npm//@types/mongodb", 23 | "@npm//async-retry", 24 | "@npm//inversify", 25 | "@npm//kafkajs", 26 | "@npm//mongodb", 27 | "@npm//rxjs", 28 | ], 29 | ) 30 | 31 | ts_jest( 32 | name = "test", 33 | srcs = glob(include = ["**/*.spec.ts"]), 34 | test_lib = "event-sourcing", 35 | tsconfig = "//:tsconfig.json", 36 | ) 37 | -------------------------------------------------------------------------------- /packages/schemas/user/index.ts: -------------------------------------------------------------------------------- 1 | import {EventTopics} from '@centsideas/enums'; 2 | 3 | import {SchemaService} from '../schema.service'; 4 | import {SchemaMessage} from '../schema-message'; 5 | import {SerializableMessage} from '../serializable-message'; 6 | 7 | export const UserCommandService: SchemaService = { 8 | proto: 'user-commands.proto', 9 | package: 'user', 10 | service: 'UserCommands', 11 | }; 12 | 13 | export const UserReadService: SchemaService = { 14 | proto: 'user-read.proto', 15 | package: 'user', 16 | service: 'UserRead', 17 | }; 18 | 19 | @SerializableMessage(EventTopics.User) 20 | export class UserEventMessage extends SchemaMessage { 21 | name = 'UserEvent'; 22 | package = 'user'; 23 | proto = 'user-events.proto'; 24 | } 25 | 26 | @SerializableMessage(EventTopics.PrivateUser) 27 | export class PrivateUserEventMessage extends SchemaMessage { 28 | name = 'PrivateUserEvent'; 29 | package = 'user'; 30 | proto = 'user-events.proto'; 31 | } 32 | 33 | export * as UserCommands from './user-commands.schema'; 34 | export * as UserReadQueries from './user-read.schema'; 35 | -------------------------------------------------------------------------------- /services/gateway/search.controller.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'inversify'; 2 | import * as express from 'express'; 3 | 4 | import {httpGet, controller} from 'inversify-express-utils'; 5 | import {RpcClient, RPC_CLIENT_FACTORY, RpcClientFactory} from '@centsideas/rpc'; 6 | import {SearchQueries, SearchService} from '@centsideas/schemas'; 7 | import {GatewayConfig} from './gateway.config'; 8 | 9 | @controller(`/search`) 10 | export class SearchController { 11 | private searchRpc: RpcClient = this.rpcFactory({ 12 | host: this.config.get('search.rpc.host'), 13 | service: SearchService, 14 | port: this.config.getNumber('search.rpc.port'), 15 | }); 16 | 17 | constructor( 18 | private config: GatewayConfig, 19 | @inject(RPC_CLIENT_FACTORY) private rpcFactory: RpcClientFactory, 20 | ) {} 21 | 22 | @httpGet('/ideas/:input') 23 | async searchIdeas(req: express.Request) { 24 | const {input} = req.params; 25 | if (!input) throw Error('please provide search input'); 26 | return this.searchRpc.client.searchIdeas({input: input.toString()}); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/angular-bazel/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary") 2 | 3 | package(default_visibility = ["//:__subpackages__"]) 4 | 5 | exports_files([ 6 | "rollup.config.js", 7 | "terser.config.json", 8 | "tsconfig.e2e.json", 9 | ] + glob(["e2e/*.js"])) 10 | 11 | filegroup( 12 | name = "rxjs_umd_modules", 13 | srcs = [ 14 | ":rxjs_shims.js", 15 | "@npm//:node_modules/rxjs/bundles/rxjs.umd.js", 16 | ], 17 | ) 18 | 19 | # Custom ts_library compiler that runs tsc_wrapped with angular/compiler-cli statically linked 20 | # This can be used with worker mode because we don't need the linker at runtime to make 21 | # the angular plugin loadable 22 | # Just a clone of @npm//@bazel/typescript/bin:tsc_wrapped with added deps 23 | nodejs_binary( 24 | name = "tsc_wrapped_with_angular", 25 | data = [ 26 | "@npm//@angular/compiler-cli", 27 | "@npm//@bazel/typescript", 28 | ], 29 | entry_point = "@npm//:node_modules/@bazel/typescript/internal/tsc_wrapped/tsc_wrapped.js", 30 | visibility = ["//:__subpackages__"], 31 | ) 32 | -------------------------------------------------------------------------------- /packages/event-sourcing/stream-event.ts: -------------------------------------------------------------------------------- 1 | import {Id} from '@centsideas/types'; 2 | 3 | import {StreamVersion} from './stream-version'; 4 | import {IDomainEvent} from './domain-event'; 5 | 6 | class StreamEvent { 7 | constructor(public readonly event: IDomainEvent, public readonly version: StreamVersion) {} 8 | } 9 | 10 | export class StreamEvents { 11 | constructor(public readonly aggregateId: Id, private readonly events: StreamEvent[]) {} 12 | 13 | static empty(id: Id) { 14 | return new StreamEvents(id, []); 15 | } 16 | 17 | add(event: IDomainEvent, version: StreamVersion) { 18 | /** 19 | * create a copy of the `StreamVersion` because it would otherwise 20 | * continue counting when this.version.next() is invoked 21 | */ 22 | const versionCopy = StreamVersion.fromNumber(version.toNumber()); 23 | this.events.push(new StreamEvent(event, versionCopy)); 24 | } 25 | 26 | toArray() { 27 | return this.events; 28 | } 29 | 30 | toEvents() { 31 | return this.events.map(e => e.event); 32 | } 33 | 34 | isEmpty() { 35 | return this.events.length <= 0; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/schemas/review/review-read.proto: -------------------------------------------------------------------------------- 1 | import "google/protobuf/empty.proto"; 2 | import "review-events.proto"; 3 | 4 | syntax = "proto3"; 5 | 6 | package review; 7 | 8 | service ReviewRead { 9 | rpc getByIdeaId(GetByIdeaId) returns (Reviews); 10 | rpc getByAuthorAndIdea(GetByAuthorAndIdea) returns (Review); 11 | rpc getByAuthor(GetByAuthor) returns (Reviews); 12 | rpc getAll(google.protobuf.Empty) returns (Reviews); 13 | } 14 | 15 | message GetByIdeaId { 16 | string ideaId = 1; 17 | optional string auid = 2; 18 | } 19 | 20 | message GetByAuthor { 21 | string authorId = 1; 22 | optional string auid = 2; 23 | } 24 | 25 | message GetByAuthorAndIdea { 26 | string ideaId = 1; 27 | string authorId = 2; 28 | optional string auid = 3; 29 | } 30 | 31 | message Review { 32 | string id = 1; 33 | string authorUserId = 2; 34 | string receiverUserId = 3; 35 | string ideaId = 4; 36 | string content = 5; 37 | ReviewScore score = 6; 38 | string publishedAt = 7; 39 | string updatedAt = 8; 40 | int32 lastEventVersion = 9; 41 | } 42 | 43 | message Reviews { 44 | repeated Review reviews = 1; 45 | } 46 | -------------------------------------------------------------------------------- /services/review/idea-read.adapter.ts: -------------------------------------------------------------------------------- 1 | import {inject, injectable} from 'inversify'; 2 | 3 | import {RpcClient, RPC_CLIENT_FACTORY, RpcClientFactory} from '@centsideas/rpc'; 4 | import {IdeaReadQueries, IdeaReadService} from '@centsideas/schemas'; 5 | import {IdeaId} from '@centsideas/types'; 6 | 7 | import {ReviewConfig} from './review.config'; 8 | import {RpcStatus} from '@centsideas/enums'; 9 | 10 | @injectable() 11 | export class IdeaReadAdapter { 12 | private ideaReadRpc: RpcClient = this.newRpcFactory({ 13 | host: this.config.get('idea-read.rpc.host'), 14 | service: IdeaReadService, 15 | port: this.config.getNumber('idea-read.rpc.port'), 16 | }); 17 | 18 | constructor( 19 | private config: ReviewConfig, 20 | @inject(RPC_CLIENT_FACTORY) private newRpcFactory: RpcClientFactory, 21 | ) {} 22 | 23 | async getPublicIdeaById(idea: IdeaId) { 24 | try { 25 | return await this.ideaReadRpc.client.getById({id: idea.toString()}); 26 | } catch (error) { 27 | if (error.code === RpcStatus.NOT_FOUND) return null; 28 | throw error; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/schemas/review/review-commands.proto: -------------------------------------------------------------------------------- 1 | import "google/protobuf/empty.proto"; 2 | 3 | import "review-events.proto"; 4 | import "../common/common.proto"; 5 | 6 | syntax = "proto3"; 7 | 8 | package review; 9 | 10 | message Create { 11 | string userId = 1; 12 | string ideaId = 2; 13 | } 14 | message Created { 15 | string id = 1; 16 | } 17 | 18 | message EditContent { 19 | string id = 1; 20 | string userId = 2; 21 | string content = 3; 22 | } 23 | 24 | message ChangeScore { 25 | string id = 1; 26 | string userId = 2; 27 | ReviewScore score = 3; 28 | } 29 | 30 | message Publish { 31 | string id = 1; 32 | string userId = 2; 33 | } 34 | 35 | message GetEventsByUserId { 36 | string userId = 1; 37 | } 38 | 39 | service ReviewCommands { 40 | rpc create(Create) returns (Created); 41 | rpc editContent(EditContent) returns (google.protobuf.Empty); 42 | rpc changeScore(ChangeScore) returns (google.protobuf.Empty); 43 | rpc publish(Publish) returns (google.protobuf.Empty); 44 | rpc getEventsByUserId(GetEventsByUserId) returns (ReviewEvents); 45 | 46 | rpc getEvents(GetEvents) returns (ReviewEvents); 47 | } -------------------------------------------------------------------------------- /services/idea/idea-read.adapter.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject} from 'inversify'; 2 | 3 | import {RpcClient, RPC_CLIENT_FACTORY, RpcClientFactory} from '@centsideas/rpc'; 4 | import {UserId} from '@centsideas/types'; 5 | import {IdeaReadQueries, IdeaReadService} from '@centsideas/schemas'; 6 | import {RpcStatus} from '@centsideas/enums'; 7 | 8 | import {IdeaConfig} from './idea.config'; 9 | 10 | @injectable() 11 | export class IdeaReadAdapter { 12 | private ideaReadRpc: RpcClient = this.newRpcFactory({ 13 | host: this.config.get('idea-read.rpc.host'), 14 | service: IdeaReadService, 15 | port: this.config.getNumber('idea-read.rpc.port'), 16 | }); 17 | 18 | constructor( 19 | private config: IdeaConfig, 20 | @inject(RPC_CLIENT_FACTORY) private newRpcFactory: RpcClientFactory, 21 | ) {} 22 | 23 | async getUnpublishedIdea(user: UserId) { 24 | try { 25 | return await this.ideaReadRpc.client.getUnpublished({userId: user.toString()}); 26 | } catch (error) { 27 | if (error.code === RpcStatus.NOT_FOUND) return null; 28 | throw error; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/schemas/user/user-commands.proto: -------------------------------------------------------------------------------- 1 | import "google/protobuf/empty.proto"; 2 | 3 | import "user-events.proto"; 4 | import "../common/common.proto"; 5 | 6 | syntax = "proto3"; 7 | 8 | package user; 9 | 10 | message RenameUser { 11 | string userId = 1; 12 | string username = 2; 13 | } 14 | 15 | message RequestDeletion { 16 | string userId = 1; 17 | } 18 | 19 | message ConfirmDeletion { 20 | string token = 1; 21 | } 22 | 23 | message RequestEmailChange { 24 | string userId = 1; 25 | string newEmail = 2; 26 | } 27 | 28 | message ConfirmEmailChange { 29 | string token = 1; 30 | } 31 | 32 | 33 | service UserCommands { 34 | rpc rename(RenameUser) returns (google.protobuf.Empty); 35 | rpc requestDeletion(RequestDeletion) returns (google.protobuf.Empty); 36 | rpc confirmDeletion(ConfirmDeletion) returns (google.protobuf.Empty); 37 | rpc requestEmailChange(RequestEmailChange) returns (google.protobuf.Empty); 38 | rpc confirmEmailChange(ConfirmEmailChange) returns (google.protobuf.Empty); 39 | 40 | rpc getEvents(GetEvents) returns (UserEvents); 41 | rpc getPrivateEvents(GetEvents) returns (PrivateUserEvents); 42 | } -------------------------------------------------------------------------------- /services/user/user-read.adapter.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject} from 'inversify'; 2 | 3 | import {RPC_CLIENT_FACTORY, RpcClientFactory, RpcClient} from '@centsideas/rpc'; 4 | import {UserReadService, UserReadQueries} from '@centsideas/schemas'; 5 | import {Username} from '@centsideas/types'; 6 | 7 | import {UserConfig} from './user.config'; 8 | import {RpcStatus} from '@centsideas/enums'; 9 | 10 | @injectable() 11 | export class UserReadAdapter { 12 | private userReadRpc: RpcClient = this.rpcClientFactory({ 13 | host: this.config.get('user-read.rpc.host'), 14 | service: UserReadService, 15 | port: this.config.getNumber('user-read.rpc.port'), 16 | }); 17 | 18 | constructor( 19 | private config: UserConfig, 20 | @inject(RPC_CLIENT_FACTORY) private rpcClientFactory: RpcClientFactory, 21 | ) {} 22 | 23 | async getUserByUsername(username: Username) { 24 | try { 25 | return await this.userReadRpc.client.getByUsername({username: username.toString()}); 26 | } catch (error) { 27 | if (error.code === RpcStatus.NOT_FOUND) return null; 28 | throw error; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/schemas/schema-message.ts: -------------------------------------------------------------------------------- 1 | import * as protobuf from 'protobufjs'; 2 | import * as path from 'path'; 3 | 4 | import {EventName} from '@centsideas/types'; 5 | import {PersistedEvent} from '@centsideas/models'; 6 | 7 | export abstract class SchemaMessage { 8 | protected abstract name: string; 9 | protected abstract package: string; 10 | protected abstract proto: string; 11 | 12 | encode(event: PersistedEvent) { 13 | const Message = this.Message; 14 | const eventName = EventName.fromString(event.name); 15 | const message = Message.create({...event, data: {[eventName.name]: event.data}}); 16 | return Message.encode(message).finish() as Buffer; 17 | } 18 | 19 | decode(buffer: Buffer, eventName: EventName): PersistedEvent { 20 | const Message = this.Message; 21 | const decoded: PersistedEvent = Object(Message.decode(buffer)); 22 | const data = (decoded.data as any)[eventName.name]; 23 | return {...decoded, data}; 24 | } 25 | 26 | private get Message() { 27 | const root = protobuf.loadSync(path.join(__dirname, this.package, this.proto)); 28 | return root.lookupType(this.name); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /services/authentication/authentication.rest: -------------------------------------------------------------------------------- 1 | @sessionId = ___ 2 | @email = mamok52762@ainbz.com 3 | @emailSignInToken = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiIxNDE0NmEwYS0yNTZhLTQ4Y2MtYjU4Ny1jZjNjYjBhNTMwYzciLCJlbWFpbCI6Im1hbW9rNTI3NjJAYWluYnouY29tIiwiaWF0IjoxNTk2NjEwNjI2LCJleHAiOjE1OTY2MTc4MjZ9.UOCcNGvO6cok2bDW4IPy3TWrNKuUP95gVFXAxGReAvg 4 | @googleSignInCode = 4/2gEy3V-Sg0xzFTevrsd8DC98TQwiQKmeigqr2YBy5iSG0Mdwh8kKemCblE1sYtmhWqJUohQa7NVfZGqnd99s3ZI 5 | 6 | ### 7 | 8 | POST {{apiUrl}}/auth/signin/email 9 | content-type: application/json 10 | 11 | {"email":"{{email}}"} 12 | 13 | ### 14 | 15 | POST {{apiUrl}}/auth/signin/email/confirm 16 | content-type: application/json 17 | 18 | {"token":"{{emailSignInToken}}"} 19 | 20 | ### 21 | 22 | GET {{apiUrl}}/auth/signin/google/url 23 | 24 | ### 25 | 26 | POST {{apiUrl}}/auth/signin/google 27 | content-type: application/json 28 | 29 | {"code":"{{googleSignInCode}}"} 30 | 31 | ### 32 | 33 | POST {{apiUrl}}/auth/refresh 34 | 35 | ### 36 | 37 | POST {{apiUrl}}/auth/signout 38 | 39 | ### 40 | 41 | POST {{apiUrl}}/auth/revoke 42 | content-type: application/json 43 | 44 | {"sessionId":"{{sessionId}}"} -------------------------------------------------------------------------------- /services/user/user.listener.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | 3 | import {EventsHandler, EventHandler} from '@centsideas/event-sourcing'; 4 | import {AuthenticationEventNames} from '@centsideas/enums'; 5 | import {PersistedEvent, SessionModels} from '@centsideas/models'; 6 | 7 | import {UserService} from './user.service'; 8 | 9 | @injectable() 10 | export class UserListener extends EventsHandler { 11 | consumerGroupName = 'centsideas.user'; 12 | 13 | constructor(private service: UserService) { 14 | super(); 15 | } 16 | 17 | @EventHandler(AuthenticationEventNames.SignInConfirmed) 18 | async signInConfirmed(event: PersistedEvent) { 19 | if (!event.data.isSignUp) return; 20 | await this.service.create(event.data.userId, event.data.email, event.data.confirmedAt); 21 | } 22 | 23 | @EventHandler(AuthenticationEventNames.GoogleSignInConfirmed) 24 | async googleSignInConfirmed(event: PersistedEvent) { 25 | if (!event.data.isSignUp) return; 26 | await this.service.create(event.data.userId, event.data.email, event.data.confirmedAt); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /services/authentication/user-read.adapter.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject} from 'inversify'; 2 | 3 | import {Email} from '@centsideas/types'; 4 | import {RpcClient, RPC_CLIENT_FACTORY, RpcClientFactory} from '@centsideas/rpc'; 5 | import {UserReadQueries, UserReadService} from '@centsideas/schemas'; 6 | import {RpcStatus} from '@centsideas/enums'; 7 | 8 | import {AuthenticationConfig} from './authentication.config'; 9 | 10 | @injectable() 11 | export class UserReadAdapter { 12 | private userReadRpc: RpcClient = this.rpcClientFactory({ 13 | host: this.config.get('user-read.rpc.host'), 14 | service: UserReadService, 15 | port: this.config.getNumber('user-read.rpc.port'), 16 | }); 17 | 18 | constructor( 19 | private config: AuthenticationConfig, 20 | @inject(RPC_CLIENT_FACTORY) private rpcClientFactory: RpcClientFactory, 21 | ) {} 22 | 23 | async getUserByEmail(email: Email) { 24 | try { 25 | return await this.userReadRpc.client.getByEmail({email: email.toString()}); 26 | } catch (error) { 27 | if (error.code === RpcStatus.NOT_FOUND) return null; 28 | throw error; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /services/idea-read/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 3 | 4 | import {DI} from '@centsideas/dependency-injection'; 5 | import {EventListener} from '@centsideas/event-sourcing'; 6 | import {Logger} from '@centsideas/utils'; 7 | import { 8 | RpcServer, 9 | RPC_SERVER_FACTORY, 10 | rpcServerFactory, 11 | RpcClient, 12 | RPC_CLIENT_FACTORY, 13 | rpcClientFactory, 14 | } from '@centsideas/rpc'; 15 | import {GlobalConfig} from '@centsideas/config'; 16 | 17 | import {IdeaReadServer} from './idea-read.server'; 18 | import {IdeaProjector} from './idea.projector'; 19 | import {IdeaRepository} from './idea.repository'; 20 | import {IdeaReadConfig} from './idea-read.config'; 21 | 22 | DI.registerProviders(IdeaReadServer, IdeaProjector, IdeaRepository); 23 | DI.registerSingletons(Logger, IdeaReadConfig, GlobalConfig); 24 | 25 | DI.registerProviders(EventListener); 26 | DI.registerProviders(RpcClient, RpcServer); 27 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 28 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 29 | 30 | DI.bootstrap(IdeaReadServer); 31 | -------------------------------------------------------------------------------- /services/idea/idea-description.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import * as Errors from './idea.errors'; 4 | import {IdeaDescription} from './idea-description'; 5 | 6 | describe('IdeaDescription', () => { 7 | const description = 'This is idea is meant to be great, but also a dummy mock test description!'; 8 | 9 | it('creates valid descriptions from string', () => { 10 | expect(() => { 11 | IdeaDescription.fromString(description); 12 | }).not.toThrow(); 13 | }); 14 | 15 | it('converts descriptions to string', () => { 16 | expect(IdeaDescription.fromString(description).toString()).toEqual(description); 17 | }); 18 | 19 | it('recognizes descriptions, that are too long', () => { 20 | const tooLong = 'too long '.repeat(500); 21 | expect(() => IdeaDescription.fromString(tooLong)).toThrowError( 22 | new Errors.IdeaDescriptionTooLong(tooLong), 23 | ); 24 | }); 25 | 26 | it('it sanitizes input strings', () => { 27 | const insane = 'This is not good!'; 28 | const sane = 'This is not good!'; 29 | expect(IdeaDescription.fromString(insane).toString()).toEqual(sane); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /services/authentication/sign-in-confirmed.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {AuthenticationEventNames} from '@centsideas/enums'; 3 | import {SessionModels} from '@centsideas/models'; 4 | import {Timestamp, UserId, Email} from '@centsideas/types'; 5 | 6 | @DomainEvent(AuthenticationEventNames.SignInConfirmed) 7 | export class SignInConfirmed implements IDomainEvent { 8 | constructor( 9 | public readonly isSignUp: boolean, 10 | public readonly userId: UserId, 11 | public readonly email: Email, 12 | public readonly confirmedAt: Timestamp, 13 | ) {} 14 | 15 | serialize(): SessionModels.SignInConfirmedData { 16 | return { 17 | isSignUp: this.isSignUp, 18 | userId: this.userId.toString(), 19 | confirmedAt: this.confirmedAt.toString(), 20 | email: this.email.toString(), 21 | }; 22 | } 23 | 24 | static deserialize({isSignUp, userId, confirmedAt, email}: SessionModels.SignInConfirmedData) { 25 | return new SignInConfirmed( 26 | isSignUp, 27 | UserId.fromString(userId), 28 | Email.fromString(email), 29 | Timestamp.fromString(confirmedAt), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /services/review-read/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 3 | 4 | import {DI} from '@centsideas/dependency-injection'; 5 | import {Logger} from '@centsideas/utils'; 6 | import {GlobalConfig} from '@centsideas/config'; 7 | import {EventListener} from '@centsideas/event-sourcing'; 8 | import { 9 | RpcClient, 10 | RpcServer, 11 | RPC_SERVER_FACTORY, 12 | rpcServerFactory, 13 | RPC_CLIENT_FACTORY, 14 | rpcClientFactory, 15 | } from '@centsideas/rpc'; 16 | import {ReviewReadServer} from './review-read.server'; 17 | import {ReviewRepository} from './review.repository'; 18 | import {ReviewProjector} from './review.projector'; 19 | import {ReviewReadConfig} from './review-read.config'; 20 | 21 | DI.registerProviders(ReviewReadServer, ReviewRepository, ReviewProjector); 22 | DI.registerSingletons(Logger, ReviewReadConfig, GlobalConfig); 23 | 24 | DI.registerProviders(EventListener); 25 | DI.registerProviders(RpcClient, RpcServer); 26 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 27 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 28 | 29 | DI.bootstrap(ReviewReadServer); 30 | -------------------------------------------------------------------------------- /packages/schemas/review/review-events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package review; 4 | 5 | message ReviewEvents { 6 | repeated ReviewEvent events = 1; 7 | } 8 | 9 | message ReviewEvent { 10 | string id = 1; 11 | string streamId = 2; 12 | int32 version = 3; 13 | string name = 4; 14 | string insertedAt = 5; 15 | int32 sequence = 6; 16 | ReviewEventData data = 7; 17 | } 18 | 19 | message ReviewEventData { 20 | oneof data { 21 | ReviewCreatedEvent created = 1; 22 | ReviewContentEditedEvent contentEdited = 2; 23 | ReviewScoreChangedEvent scoreChanged = 3; 24 | ReviewPublishedEvent published = 4; 25 | } 26 | } 27 | 28 | message ReviewScore { 29 | int32 control = 1; 30 | int32 entry = 2; 31 | int32 need = 3; 32 | int32 time = 4; 33 | int32 scale = 5; 34 | } 35 | 36 | message ReviewCreatedEvent { 37 | string id = 1; 38 | string authorUserId = 2; 39 | string receiverUserId = 3; 40 | string ideaId = 4; 41 | string createdAt = 5; 42 | } 43 | 44 | message ReviewContentEditedEvent { 45 | string content = 1; 46 | } 47 | 48 | message ReviewScoreChangedEvent { 49 | ReviewScore score = 2; 50 | } 51 | 52 | message ReviewPublishedEvent { 53 | string publishedAt = 3; 54 | } 55 | -------------------------------------------------------------------------------- /packages/schemas/user/user-read.proto: -------------------------------------------------------------------------------- 1 | import "google/protobuf/empty.proto"; 2 | 3 | syntax = "proto3"; 4 | 5 | package user; 6 | 7 | service UserRead { 8 | rpc getMe(GetMe) returns (FullUser); 9 | rpc getById(GetById) returns (User); 10 | rpc getByEmail(GeyByEmail) returns (User); 11 | rpc getEmailById(GetById) returns (Email); 12 | rpc getByUsername(GetByUsername) returns (User); 13 | rpc getAll(google.protobuf.Empty) returns (Users); 14 | } 15 | 16 | message GetMe { 17 | string id = 1; 18 | } 19 | 20 | message GetById { 21 | string id = 1; 22 | } 23 | 24 | message GeyByEmail { 25 | string email = 1; 26 | } 27 | 28 | message GetByUsername { 29 | string username = 1; 30 | } 31 | 32 | message User { 33 | string id = 1; 34 | string username = 2; 35 | string createdAt = 3; 36 | string updatedAt = 4; 37 | string deletedAt = 5; 38 | int32 lastEventVersion = 6; 39 | } 40 | 41 | message Email { 42 | string email = 1; 43 | } 44 | 45 | message Users { 46 | repeated User users = 1; 47 | } 48 | 49 | message PrivateUser { 50 | string id = 1; 51 | string email = 2; 52 | string pendingEmail = 3; 53 | int32 lastEventVersion = 4; 54 | } 55 | 56 | message FullUser { 57 | User public = 1; 58 | PrivateUser private = 2; 59 | } -------------------------------------------------------------------------------- /services/review/review-score.ts: -------------------------------------------------------------------------------- 1 | import {ReviewScoreValue} from '@centsideas/enums'; 2 | import {ReviewModels} from '@centsideas/models'; 3 | 4 | import * as Errors from './review.errors'; 5 | 6 | export class ReviewScore { 7 | private constructor(private score: ReviewModels.Score) { 8 | const keys: (keyof ReviewModels.Score)[] = ['control', 'entry', 'need', 'time', 'scale']; 9 | for (const key of keys) { 10 | this.checkScoreValue(score[key], key); 11 | } 12 | } 13 | 14 | private checkScoreValue(value: number, key: string) { 15 | if (isNaN(value) || value > ReviewScoreValue.Max || value < ReviewScoreValue.Min) 16 | throw new Errors.ReviewScoreInvalid(value, key); 17 | } 18 | 19 | static fromObject(score: ReviewModels.Score) { 20 | return new ReviewScore(score); 21 | } 22 | 23 | toObject() { 24 | return this.score; 25 | } 26 | 27 | equals(other: ReviewScore) { 28 | const otherObj = other.toObject(); 29 | const thisObj = this.toObject(); 30 | return ( 31 | otherObj.control === thisObj.control && 32 | otherObj.entry === thisObj.entry && 33 | otherObj.need === thisObj.need && 34 | otherObj.time === thisObj.time && 35 | otherObj.scale === thisObj.scale 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /services/review/review-read.adapter.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject} from 'inversify'; 2 | 3 | import {RpcClient, RPC_CLIENT_FACTORY, RpcClientFactory} from '@centsideas/rpc'; 4 | import {ReviewQueries, ReviewReadService} from '@centsideas/schemas'; 5 | import {UserId, IdeaId} from '@centsideas/types'; 6 | import {RpcStatus} from '@centsideas/enums'; 7 | 8 | import {ReviewConfig} from './review.config'; 9 | 10 | @injectable() 11 | export class ReviewReadAdapter { 12 | private reviewReadRpc: RpcClient = this.newRpcFactory({ 13 | host: this.config.get('review-read.rpc.host'), 14 | service: ReviewReadService, 15 | port: this.config.getNumber('review-read.rpc.port'), 16 | }); 17 | 18 | constructor( 19 | private config: ReviewConfig, 20 | @inject(RPC_CLIENT_FACTORY) private newRpcFactory: RpcClientFactory, 21 | ) {} 22 | 23 | async getByAuthorAndIdea(author: UserId, idea: IdeaId) { 24 | try { 25 | return await this.reviewReadRpc.client.getByAuthorAndIdea({ 26 | authorId: author.toString(), 27 | ideaId: idea.toString(), 28 | auid: author.toString(), 29 | }); 30 | } catch (error) { 31 | if (error.code === RpcStatus.NOT_FOUND) return null; 32 | throw error; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/schemas/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | 3 | package(default_visibility = ["//:__subpackages__"]) 4 | 5 | filegroup( 6 | name = "all_proto", 7 | srcs = glob(["**/*.proto"]), 8 | ) 9 | 10 | filegroup( 11 | name = "proto_common", 12 | srcs = glob(["**/common/*.proto"]), 13 | ) 14 | 15 | filegroup( 16 | name = "proto_authentication", 17 | srcs = glob(["**/authentication/*.proto"]), 18 | ) 19 | 20 | filegroup( 21 | name = "proto_idea", 22 | srcs = glob(["**/idea/*.proto"]), 23 | ) 24 | 25 | filegroup( 26 | name = "proto_user", 27 | srcs = glob(["**/user/*.proto"]), 28 | ) 29 | 30 | filegroup( 31 | name = "proto_search", 32 | srcs = glob(["**/search/*.proto"]), 33 | ) 34 | 35 | filegroup( 36 | name = "proto_review", 37 | srcs = glob(["**/review/*.proto"]), 38 | ) 39 | 40 | ts_library( 41 | name = "schemas", 42 | srcs = glob(["**/*.ts"]), 43 | module_name = "@centsideas/schemas", 44 | deps = [ 45 | "//packages/enums", 46 | "//packages/models", 47 | "//packages/types", 48 | "@npm//@grpc/grpc-js", 49 | "@npm//@grpc/proto-loader", 50 | "@npm//@types/async-retry", 51 | "@npm//@types/node", 52 | "@npm//protobufjs", 53 | ], 54 | ) 55 | -------------------------------------------------------------------------------- /services/authentication/sign-in-requested.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {AuthenticationEventNames} from '@centsideas/enums'; 3 | 4 | import {SessionModels} from '@centsideas/models'; 5 | import {Email, Timestamp, SessionId} from '@centsideas/types'; 6 | 7 | import {SignInMethod} from './sign-in-method'; 8 | 9 | @DomainEvent(AuthenticationEventNames.SignInRequested) 10 | export class SignInRequested implements IDomainEvent { 11 | constructor( 12 | public readonly sessionId: SessionId, 13 | public readonly method: SignInMethod, 14 | public readonly email: Email, 15 | public readonly requestedAt: Timestamp, 16 | ) {} 17 | 18 | serialize(): SessionModels.SignInRequestedData { 19 | return { 20 | sessionId: this.sessionId.toString(), 21 | method: this.method.toString(), 22 | email: this.email.toString(), 23 | requestedAt: this.requestedAt.toString(), 24 | }; 25 | } 26 | 27 | static deserialize({sessionId, method, email, requestedAt}: SessionModels.SignInRequestedData) { 28 | return new SignInRequested( 29 | SessionId.fromString(sessionId), 30 | SignInMethod.fromString(method), 31 | Email.fromString(email), 32 | Timestamp.fromString(requestedAt), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /services/idea/idea.rest: -------------------------------------------------------------------------------- 1 | @ideaId = {{create.response.body.id}} 2 | 3 | ### 4 | 5 | # @name create 6 | POST {{apiUrl}}/idea 7 | Authorization: Bearer {{userId}} 8 | 9 | ### 10 | 11 | PUT {{apiUrl}}/idea/{{ideaId}}/rename 12 | content-type: application/json 13 | Authorization: Bearer {{userId}} 14 | 15 | {"title":"Marketplace for 3D Models and similar assets"} 16 | 17 | ### 18 | 19 | PUT {{apiUrl}}/idea/{{ideaId}}/description 20 | content-type: application/json 21 | Authorization: Bearer {{userId}} 22 | 23 | {"description":"A platform where people cann sell and buy 3d models"} 24 | 25 | ### 26 | 27 | PUT {{apiUrl}}/idea/{{ideaId}}/tags 28 | content-type: application/json 29 | Authorization: Bearer {{userId}} 30 | 31 | {"tags": ["3d","marketplace","ecommerce"]} 32 | 33 | ### 34 | 35 | PUT {{apiUrl}}/idea/{{ideaId}}/tags 36 | content-type: application/json 37 | Authorization: Bearer {{userId}} 38 | 39 | {"tags": ["awesome","cool","best"]} 40 | 41 | ### 42 | 43 | PUT {{apiUrl}}/idea/{{ideaId}}/publish 44 | Authorization: Bearer {{userId}} 45 | 46 | ### 47 | 48 | DELETE {{apiUrl}}/idea/{{ideaId}} 49 | Authorization: Bearer {{userId}} 50 | 51 | ### errors 52 | 53 | PUT {{apiUrl}}/idea/{{ideaId}}/rename 54 | content-type: application/json 55 | Authorization: Bearer {{userId}} 56 | 57 | {"title":"no"} 58 | -------------------------------------------------------------------------------- /services/idea/idea-title.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import * as Errors from './idea.errors'; 4 | import {IdeaTitle} from './idea-title'; 5 | 6 | describe('IdeaTitle', () => { 7 | const title = 'My awesome idea'; 8 | 9 | it('creates valid titles from string', () => { 10 | expect(() => { 11 | IdeaTitle.fromString(title); 12 | }).not.toThrow(); 13 | }); 14 | 15 | it('converts titles to string', () => { 16 | expect(IdeaTitle.fromString(title).toString()).toEqual(title); 17 | }); 18 | 19 | it('recognizes titles, that are too short', () => { 20 | const tooShort = 'no'; 21 | expect(() => IdeaTitle.fromString(tooShort)).toThrowError( 22 | new Errors.IdeaTitleTooShort(tooShort), 23 | ); 24 | expect(() => IdeaTitle.fromString('')).toThrowError(new Errors.IdeaTitleTooShort('')); 25 | }); 26 | 27 | it('recognizes titles, that are too long', () => { 28 | const tooLong = 'too long '.repeat(50); 29 | expect(() => IdeaTitle.fromString(tooLong)).toThrowError(new Errors.IdeaTitleTooLong(tooLong)); 30 | }); 31 | 32 | it('it sanitizes input strings', () => { 33 | const insane = 'This is not good!'; 34 | const sane = 'This is not good!'; 35 | expect(IdeaTitle.fromString(insane).toString()).toEqual(sane); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/schemas/idea/idea-commands.schema.ts: -------------------------------------------------------------------------------- 1 | import {PersistedEvent} from '@centsideas/models'; 2 | 3 | import {GetEventsCommand} from '../common'; 4 | 5 | export interface CreateIdea { 6 | userId: string; 7 | } 8 | 9 | export interface RenameIdea { 10 | id: string; 11 | userId: string; 12 | title: string; 13 | } 14 | 15 | export interface EditIdeaDescription { 16 | id: string; 17 | userId: string; 18 | description: string; 19 | } 20 | 21 | export interface UpdateIdeaTags { 22 | id: string; 23 | userId: string; 24 | tags: string[]; 25 | } 26 | 27 | export interface PublishIdea { 28 | id: string; 29 | userId: string; 30 | } 31 | 32 | export interface DeleteIdea { 33 | id: string; 34 | userId: string; 35 | } 36 | 37 | export interface GetByUserId { 38 | userId: string; 39 | } 40 | 41 | export interface Service { 42 | create: (payload: CreateIdea) => Promise<{id: string}>; 43 | rename: (payload: RenameIdea) => Promise; 44 | editDescription: (payload: EditIdeaDescription) => Promise; 45 | updateTags: (payload: UpdateIdeaTags) => Promise; 46 | publish: (payload: PublishIdea) => Promise; 47 | delete: (payload: DeleteIdea) => Promise; 48 | getEventsByUserId: (payload: GetByUserId) => Promise<{events: PersistedEvent[]}>; 49 | 50 | getEvents: GetEventsCommand; 51 | } 52 | -------------------------------------------------------------------------------- /services/review/review-created.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {ReviewId, UserId, IdeaId, Timestamp} from '@centsideas/types'; 3 | import {ReviewModels} from '@centsideas/models'; 4 | import {ReviewEventNames} from '@centsideas/enums'; 5 | 6 | @DomainEvent(ReviewEventNames.Created) 7 | export class ReviewCreated implements IDomainEvent { 8 | constructor( 9 | public readonly id: ReviewId, 10 | public readonly author: UserId, 11 | public readonly receiver: UserId, 12 | public readonly idea: IdeaId, 13 | public readonly createdAt: Timestamp, 14 | ) {} 15 | 16 | serialize(): ReviewModels.CreatedData { 17 | return { 18 | id: this.id.toString(), 19 | authorUserId: this.author.toString(), 20 | receiverUserId: this.receiver.toString(), 21 | ideaId: this.idea.toString(), 22 | createdAt: this.createdAt.toString(), 23 | }; 24 | } 25 | 26 | static deserialize({ 27 | id, 28 | authorUserId, 29 | receiverUserId, 30 | ideaId, 31 | createdAt, 32 | }: ReviewModels.CreatedData) { 33 | return new ReviewCreated( 34 | ReviewId.fromString(id), 35 | UserId.fromString(authorUserId), 36 | UserId.fromString(receiverUserId), 37 | IdeaId.fromString(ideaId), 38 | Timestamp.fromString(createdAt), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/event-sourcing/apply-event.ts: -------------------------------------------------------------------------------- 1 | import {EventName} from '@centsideas/types/event-name'; 2 | 3 | import {DomainEventInstance, EVENT_NAME_METADATA} from './domain-event'; 4 | import {Aggregate} from './aggregate'; 5 | 6 | /** 7 | * Takes an event class as an argument of the decorator 8 | * The method which is decorated will be called to handle 9 | * the aggregates state update 10 | */ 11 | export const Apply = (Event: DomainEventInstance) => { 12 | return function ApplyDecorator(target: any, propertyKey: string) { 13 | if (!(target instanceof Aggregate)) 14 | throw new Error( 15 | `@Apply() decorator can only be used inside an Aggregate class.` + 16 | ` But ${target} does not extend Aggregate!`, 17 | ); 18 | /** 19 | * Get the event's name from the metadata saved on the 20 | * class of the event 21 | */ 22 | const eventName = EventName.fromString( 23 | Reflect.getMetadata(EVENT_NAME_METADATA, Event.prototype), 24 | ); 25 | /** 26 | * Save the @param propertyKey of the handler method 27 | * on the @param target class and associate it with the 28 | * @param eventName 29 | * 30 | * This metadata is used in the @method apply of the 31 | * aggragate base class 32 | */ 33 | Reflect.defineMetadata(eventName.toString(), propertyKey, target); 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/schemas/idea/idea-events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package idea; 4 | 5 | message IdeaEvents { 6 | repeated IdeaEvent events = 1; 7 | } 8 | 9 | message IdeaEvent { 10 | string id = 1; 11 | string streamId = 2; 12 | int32 version = 3; 13 | string name = 4; 14 | string insertedAt = 5; 15 | int32 sequence = 6; 16 | IdeaEventData data = 7; 17 | } 18 | 19 | message IdeaEventData { 20 | oneof data { 21 | IdeaCreatedEvent created = 1; 22 | IdeaRenamed renamed = 2; 23 | IdeaDescriptionEdited descriptionEdited = 3; 24 | IdeaTagsAdded tagsAdded = 4; 25 | IdeaTagsRemoved tagsRemoved = 5; 26 | IdeaPublished published = 6; 27 | IdeaDeleted deleted = 7; 28 | } 29 | } 30 | 31 | message IdeaCreatedEvent { 32 | string id = 1; 33 | string userId = 2; 34 | string createdAt = 3; 35 | } 36 | 37 | message IdeaRenamed { 38 | string id = 1; 39 | string title = 2; 40 | } 41 | 42 | message IdeaDescriptionEdited { 43 | string id = 1; 44 | string description = 2; 45 | } 46 | 47 | message IdeaTagsAdded { 48 | string id = 1; 49 | repeated string tags = 2; 50 | } 51 | 52 | message IdeaTagsRemoved { 53 | string id = 1; 54 | repeated string tags = 2; 55 | } 56 | 57 | message IdeaPublished { 58 | string id = 1; 59 | string publishedAt = 2; 60 | } 61 | 62 | message IdeaDeleted { 63 | string id = 1; 64 | string deletedAt = 2; 65 | } 66 | -------------------------------------------------------------------------------- /services/user-read/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 3 | 4 | import {DI} from '@centsideas/dependency-injection'; 5 | import {Logger} from '@centsideas/utils'; 6 | import {GlobalConfig} from '@centsideas/config'; 7 | import {EventListener} from '@centsideas/event-sourcing'; 8 | import { 9 | RpcClient, 10 | RpcServer, 11 | RPC_SERVER_FACTORY, 12 | rpcServerFactory, 13 | RPC_CLIENT_FACTORY, 14 | rpcClientFactory, 15 | } from '@centsideas/rpc'; 16 | 17 | import {UserReadConfig} from './user-read.config'; 18 | import {UserReadServer} from './user-read.server'; 19 | import {UserRepository} from './user.repository'; 20 | import {PrivateUserRepository} from './private-user.repository'; 21 | import {UserProjector} from './user.projector'; 22 | import {PrivateUserProjector} from './private-user.projector'; 23 | 24 | DI.registerProviders( 25 | UserReadServer, 26 | UserRepository, 27 | PrivateUserRepository, 28 | UserProjector, 29 | PrivateUserProjector, 30 | ); 31 | DI.registerSingletons(Logger, UserReadConfig, GlobalConfig); 32 | 33 | DI.registerProviders(EventListener); 34 | DI.registerProviders(RpcClient, RpcServer); 35 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 36 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 37 | 38 | DI.bootstrap(UserReadServer); 39 | -------------------------------------------------------------------------------- /packages/types/id.ts: -------------------------------------------------------------------------------- 1 | import * as shortid from 'shortid'; 2 | import {v4 as uuidv4} from 'uuid'; 3 | 4 | import {Exception} from '@centsideas/utils'; 5 | import {RpcStatus, GenericErrorNames} from '@centsideas/enums'; 6 | 7 | abstract class BaseId { 8 | protected constructor(protected readonly id: string) {} 9 | 10 | equals(that: Id) { 11 | return this.id === that.toString(); 12 | } 13 | 14 | toString() { 15 | return this.id; 16 | } 17 | } 18 | 19 | export class UUId extends BaseId { 20 | protected static regex = new RegExp( 21 | /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i, 22 | ); 23 | 24 | static generate() { 25 | return new UUId(uuidv4()); 26 | } 27 | 28 | static fromString(id: string) { 29 | if (!UUId.regex.test(id)) throw new InvalidId(id); 30 | return new UUId(id); 31 | } 32 | } 33 | 34 | export class ShortId extends BaseId { 35 | static generate() { 36 | return new ShortId(shortid()); 37 | } 38 | 39 | static fromString(id: string) { 40 | if (!shortid.isValid(id)) throw new InvalidId(id); 41 | return new ShortId(id); 42 | } 43 | } 44 | 45 | export type Id = UUId | ShortId; 46 | 47 | export class InvalidId extends Exception { 48 | code = RpcStatus.INVALID_ARGUMENT; 49 | name = GenericErrorNames.InvalidId; 50 | 51 | constructor(id: string) { 52 | super(`Id: "${id}" is invalid`); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/event-sourcing/in-memory-projector.ts: -------------------------------------------------------------------------------- 1 | import {postConstruct, inject} from 'inversify'; 2 | import {from} from 'rxjs'; 3 | import {concatMap} from 'rxjs/operators'; 4 | 5 | import {PersistedEvent} from '@centsideas/models'; 6 | import {EventTopics} from '@centsideas/enums'; 7 | 8 | import {Projector} from './projector'; 9 | import {EventListener} from './event-bus'; 10 | 11 | export abstract class InMemoryProjector extends Projector { 12 | abstract topic: EventTopics; 13 | abstract consumerGroupName: string; 14 | abstract async getEvents(from: number): Promise; 15 | 16 | @inject(EventListener) private eventListener!: EventListener; 17 | private bookmark = 0; 18 | protected documents: Record = {}; 19 | 20 | @postConstruct() 21 | initializeProjector() { 22 | this.replay(); 23 | this.eventListener 24 | .listen(this.topic, this.consumerGroupName) 25 | .pipe(concatMap(event => from(this.trigger(event)))) 26 | .subscribe(); 27 | } 28 | 29 | async replay() { 30 | const bookmark = await this.getBookmark(); 31 | const events = await this.getEvents(bookmark); 32 | if (!events) return; 33 | 34 | for (const event of events) { 35 | await this.trigger(event); 36 | } 37 | } 38 | 39 | async getBookmark() { 40 | return this.bookmark; 41 | } 42 | 43 | async increaseBookmark() { 44 | this.bookmark++; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/event-sourcing/event-handler.ts: -------------------------------------------------------------------------------- 1 | import {EventTopics} from '@centsideas/enums'; 2 | import {EventName} from '@centsideas/types/event-name'; 3 | 4 | import {EventsHandler} from './events-handler'; 5 | 6 | export const EVENTS_HANDLER_TOPICS = '__eventsHandlerTopics__'; 7 | 8 | /** 9 | * Use this decorator only for actions that need to happen ONCE. 10 | * It is okay to use on write-side 11 | * It is not okay to use in projectors! 12 | */ 13 | export const EventHandler = (eventNameString: string) => { 14 | return (target: any, propertyKey: string) => { 15 | if (!(target instanceof EventsHandler)) 16 | throw new Error( 17 | `@EventsHandler() decorator can only be used inside an EventsHandler class.` + 18 | ` But ${target} does not extend EventsHandler!`, 19 | ); 20 | const eventName = EventName.fromString(eventNameString); 21 | Reflect.defineMetadata(eventName.toString(), propertyKey, target); 22 | 23 | const topic = eventName.getTopic(); 24 | if ( 25 | !Object.values(EventTopics) 26 | .map(t => t.toString()) 27 | .includes(topic) 28 | ) 29 | throw new Error(`No topic ${topic} found in EventTopics enum`); 30 | 31 | const topics: string[] = Reflect.getMetadata(EVENTS_HANDLER_TOPICS, target) || []; 32 | const updatedTopics = topics.includes(topic) ? topics : [...topics, topic]; 33 | Reflect.defineMetadata(EVENTS_HANDLER_TOPICS, updatedTopics, target); 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/schemas/idea/idea-commands.proto: -------------------------------------------------------------------------------- 1 | import "google/protobuf/empty.proto"; 2 | 3 | import "idea-events.proto"; 4 | import "../common/common.proto"; 5 | 6 | syntax = "proto3"; 7 | 8 | package idea; 9 | 10 | message CreateIdea { 11 | string userId = 1; 12 | } 13 | message IdeaCreated { 14 | string id = 1; 15 | } 16 | 17 | message RenameIdea { 18 | string id = 1; 19 | string userId = 2; 20 | string title = 3; 21 | } 22 | 23 | message EditIdeaDescription { 24 | string id = 1; 25 | string userId = 2; 26 | string description = 3; 27 | } 28 | 29 | message UpdateIdeaTags { 30 | string id = 1; 31 | string userId = 2; 32 | repeated string tags = 3; 33 | } 34 | 35 | message PublishIdea { 36 | string id = 1; 37 | string userId = 2; 38 | } 39 | 40 | message DeleteIdea { 41 | string id = 1; 42 | string userId = 2; 43 | } 44 | 45 | message GetEventsByUserId { 46 | string userId = 1; 47 | } 48 | 49 | service IdeaCommands { 50 | rpc create(CreateIdea) returns (IdeaCreated); 51 | rpc rename(RenameIdea) returns (google.protobuf.Empty); 52 | rpc editDescription(EditIdeaDescription) returns (google.protobuf.Empty); 53 | rpc updateTags(UpdateIdeaTags) returns (google.protobuf.Empty); 54 | rpc publish(PublishIdea) returns (google.protobuf.Empty); 55 | rpc delete(DeleteIdea) returns (google.protobuf.Empty); 56 | rpc getEventsByUserId(GetEventsByUserId) returns (IdeaEvents); 57 | 58 | rpc getEvents(GetEvents) returns (IdeaEvents); 59 | } -------------------------------------------------------------------------------- /packages/enums/events.ts: -------------------------------------------------------------------------------- 1 | export enum IdeaEventNames { 2 | Created = 'idea:created', 3 | Renamed = 'idea:renamed', 4 | DescriptionEdited = 'idea:descriptionEdited', 5 | TagsAdded = 'idea:tagsAdded', 6 | TagsRemoved = 'idea:tagsRemoved', 7 | Published = 'idea:published', 8 | Deleted = 'idea:deleted', 9 | } 10 | 11 | export enum AuthenticationEventNames { 12 | SignInRequested = 'authentication.session:signInRequested', 13 | SignInConfirmed = 'authentication.session:signInConfirmed', 14 | GoogleSignInConfirmed = 'authentication.session:googleSignInConfirmed', 15 | SignedOut = 'authentication.session:signedOut', 16 | TokensRefreshed = 'authentication.session:tokensRefreshed', 17 | RefreshTokenRevoked = 'authentication.session:refreshTokenRevoked', 18 | } 19 | 20 | export enum PrivateUserEventNames { 21 | Created = 'user.privateUser:created', 22 | EmailChangeRequested = 'user.privateUser:emailChangeRequested', 23 | EmailChangeConfirmed = 'user.privateUser:emailChangeConfirmed', 24 | Deleted = 'user.privateUser:deleted', 25 | } 26 | 27 | export enum UserEventNames { 28 | Created = 'user:created', 29 | Renamed = 'user:renamed', 30 | DeletionRequested = 'user:deletionRequested', 31 | DeletionConfirmed = 'user:deletionConfirmed', 32 | } 33 | 34 | export enum ReviewEventNames { 35 | Created = 'review:created', 36 | ContentEdited = 'review:contentEdited', 37 | ScoreChanged = 'review:scoreChanged', 38 | Published = 'review:published', 39 | } 40 | -------------------------------------------------------------------------------- /packages/kubernetes/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@k8s_deploy//:defaults.bzl", "k8s_deploy") 2 | load("@microk8s_deploy//:defaults.bzl", "microk8s_deploy") 3 | load("@io_bazel_rules_k8s//k8s:objects.bzl", "k8s_objects") 4 | 5 | package(default_visibility = ["//visibility:public"]) 6 | 7 | k8s_deploy( 8 | name = "certificate_issuer", 9 | template = ":certificate-issuer.yaml", 10 | ) 11 | 12 | k8s_deploy( 13 | name = "ingress", 14 | template = ":ingress.yaml", 15 | ) 16 | 17 | k8s_deploy( 18 | name = "config", 19 | template = ":global-config.yaml", 20 | ) 21 | 22 | k8s_deploy( 23 | name = "secrets", 24 | template = ":secrets.yaml", 25 | ) 26 | 27 | k8s_deploy( 28 | name = "storage", 29 | template = ":storage.yaml", 30 | ) 31 | 32 | k8s_objects( 33 | name = "defaults", 34 | objects = [ 35 | ":certificate_issuer", 36 | ":config", 37 | ":ingress", 38 | ":secrets", 39 | ":storage", 40 | ], 41 | ) 42 | 43 | microk8s_deploy( 44 | name = "microk8s_config", 45 | template = ":global-config.yaml", 46 | ) 47 | 48 | microk8s_deploy( 49 | name = "microk8s_ingress", 50 | template = ":local-ingress.yaml", 51 | ) 52 | 53 | microk8s_deploy( 54 | name = "microk8s_secrets", 55 | template = ":secrets.yaml", 56 | ) 57 | 58 | k8s_objects( 59 | name = "microk8s_defaults", 60 | objects = [ 61 | ":microk8s_config", 62 | ":microk8s_ingress", 63 | ":microk8s_secrets", 64 | ], 65 | ) 66 | -------------------------------------------------------------------------------- /packages/angular-bazel/tools/ngsw_config.bzl: -------------------------------------------------------------------------------- 1 | load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary") 2 | 3 | def ngsw_config(name, config, index_html, src, out = None, **kwargs): 4 | """Creates ngsw.json with service worker configuration and hashes for all source files" 5 | 6 | Args: 7 | name: name 8 | config: ngsw.config.json file 9 | index_html: index.html file 10 | src: pkg_web assets 11 | out: out file 12 | **kwargs: 13 | 14 | Credits: https://github.com/marcus-sa 15 | """ 16 | if not out: 17 | out = name 18 | 19 | ngsw_config_name = "%s_bin" % name 20 | 21 | nodejs_binary( 22 | name = ngsw_config_name, 23 | data = ["@npm//@angular/service-worker", index_html, config, src], 24 | visibility = ["//visibility:private"], 25 | entry_point = "@npm//:node_modules/@angular/service-worker/ngsw-config.js", 26 | ) 27 | 28 | cmd = """ 29 | mkdir -p $@ 30 | cp -R $(locations {TMPL_src})/. $@/ 31 | cp $(location {TMPL_index}) $@/index.html 32 | $(location :{TMPL_bin}) $@ $(location {TMPL_conf}) 33 | """.format( 34 | TMPL_src = src, 35 | TMPL_bin = ngsw_config_name, 36 | TMPL_index = index_html, 37 | TMPL_conf = config, 38 | ) 39 | 40 | native.genrule( 41 | name = name, 42 | outs = [out], 43 | srcs = [src, config, index_html], 44 | tools = [":" + ngsw_config_name], 45 | cmd = cmd, 46 | **kwargs 47 | ) 48 | -------------------------------------------------------------------------------- /services/authentication/session.errors.ts: -------------------------------------------------------------------------------- 1 | import {Exception} from '@centsideas/utils'; 2 | import {RpcStatus, AuthenticationErrorNames} from '@centsideas/enums'; 3 | import {SessionId} from '@centsideas/types'; 4 | 5 | export class SessionAlreadyConfirmed extends Exception { 6 | code = RpcStatus.FAILED_PRECONDITION; 7 | name = AuthenticationErrorNames.SessionAlreadyConfirmed; 8 | 9 | constructor() { 10 | super(`Session already confirmed. Session cannot be confirmed twice.`); 11 | } 12 | } 13 | 14 | export class SessionRevoked extends Exception { 15 | code = RpcStatus.FAILED_PRECONDITION; 16 | name = AuthenticationErrorNames.SessionRevoked; 17 | 18 | constructor() { 19 | super(`Session was revoked.`); 20 | } 21 | } 22 | 23 | export class SessionSignedOut extends Exception { 24 | code = RpcStatus.FAILED_PRECONDITION; 25 | name = AuthenticationErrorNames.SessionSignedOut; 26 | 27 | constructor() { 28 | super(`You have already signed out of this session.`); 29 | } 30 | } 31 | 32 | export class SessionUnconfirmed extends Exception { 33 | code = RpcStatus.FAILED_PRECONDITION; 34 | name = AuthenticationErrorNames.SessionUnconfirmed; 35 | 36 | constructor() { 37 | super(`This session hasn't bee confirmed yet.`); 38 | } 39 | } 40 | 41 | export class SessionNotFound extends Exception { 42 | name = AuthenticationErrorNames.SessionNotFound; 43 | code = RpcStatus.NOT_FOUND; 44 | 45 | constructor(id: SessionId) { 46 | super(`Session with id ${id.toString()} was not found`); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/dependency-injection/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import {Container, interfaces} from 'inversify'; 3 | 4 | /** 5 | * `skipBaseClassChecks: true` 6 | * https://github.com/inversify/InversifyJS/blob/master/wiki/inheritance.md#workaround-e-skip-base-class-injectable-checks 7 | * is needed because of the abstract `EventRepository` class 8 | */ 9 | const container = new Container({skipBaseClassChecks: true}); 10 | 11 | export const DI = { 12 | registerProviders: (...providers: any[]) => providers.forEach(p => container.bind(p).toSelf()), 13 | 14 | registerFactory: (identifier: any, factory: (context: interfaces.Context) => any) => 15 | container.bind(identifier).toFactory(factory), 16 | 17 | registerConstant: (identifier: any, constant: any) => 18 | container.bind(identifier).toConstantValue(constant), 19 | 20 | registerSingleton: (identifier: any, provider?: any) => 21 | provider 22 | ? container.bind(identifier).to(provider).inSingletonScope() 23 | : container.bind(identifier).toSelf().inSingletonScope(), 24 | 25 | registerSingletons: (...providers: any[]) => 26 | providers.forEach(p => container.bind(p).to(p).inSingletonScope()), 27 | 28 | getProvider: (provider: any): any => container.get(provider), 29 | bootstrap: (provider: any): any => container.get(provider), 30 | 31 | overrideProvider: (provider: any, newProvider: any) => { 32 | container.unbind(provider); 33 | container.bind(provider).to(newProvider); 34 | }, 35 | 36 | getContainer: () => container, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/schemas/authentication/authentication-commands.schema.ts: -------------------------------------------------------------------------------- 1 | import {GetEventsCommand} from '../common'; 2 | import {PersistedEvent} from '@centsideas/models'; 3 | 4 | export interface RequestEmailSignIn { 5 | email: string; 6 | } 7 | 8 | export interface ConfirmEmailSignIn { 9 | signInToken: string; 10 | } 11 | 12 | export interface GoogleSignIn { 13 | code: string; 14 | } 15 | 16 | export interface RefreshTokens { 17 | refreshToken: string; 18 | } 19 | 20 | export interface SignOut { 21 | refreshToken: string; 22 | } 23 | 24 | export interface RevokeRefreshToken { 25 | sessionId: string; 26 | } 27 | 28 | export interface AuthTokenResponse { 29 | refreshToken: string; 30 | accessToken: string; 31 | userId: string; 32 | } 33 | 34 | export interface GoogleSignInUrlResponse { 35 | url: string; 36 | } 37 | 38 | export interface GetEventsByUserId { 39 | userId: string; 40 | } 41 | 42 | export interface Service { 43 | requestEmailSignIn: (payload: RequestEmailSignIn) => Promise; 44 | confirmEmailSignIn: (payload: ConfirmEmailSignIn) => Promise; 45 | googleSignInUrl: (payload: void) => Promise; 46 | googleSignIn: (payload: GoogleSignIn) => Promise; 47 | refreshToken: (payload: RefreshTokens) => Promise; 48 | signOut: (payload: SignOut) => Promise; 49 | revokeRefreshToken: (payload: RevokeRefreshToken) => Promise; 50 | getEventsByUserId: (payload: GetEventsByUserId) => Promise<{events: PersistedEvent[]}>; 51 | 52 | getEvents: GetEventsCommand; 53 | } 54 | -------------------------------------------------------------------------------- /services/authentication/google-sign-in-confirmed.ts: -------------------------------------------------------------------------------- 1 | import {DomainEvent, IDomainEvent} from '@centsideas/event-sourcing'; 2 | import {AuthenticationEventNames} from '@centsideas/enums'; 3 | import {SessionModels} from '@centsideas/models'; 4 | import {Timestamp, UserId, SessionId, Email} from '@centsideas/types'; 5 | 6 | @DomainEvent(AuthenticationEventNames.GoogleSignInConfirmed) 7 | export class GoogleSignInConfirmed implements IDomainEvent { 8 | constructor( 9 | public readonly sessionId: SessionId, 10 | public readonly userId: UserId, 11 | public readonly email: Email, 12 | public readonly isSignUp: boolean, 13 | public readonly requestedAt: Timestamp, 14 | public readonly confirmedAt: Timestamp, 15 | ) {} 16 | 17 | serialize(): SessionModels.GoogleSignInConfirmedData { 18 | return { 19 | sessionId: this.sessionId.toString(), 20 | userId: this.userId.toString(), 21 | email: this.email.toString(), 22 | isSignUp: this.isSignUp, 23 | requestedAt: this.requestedAt.toString(), 24 | confirmedAt: this.confirmedAt.toString(), 25 | }; 26 | } 27 | 28 | static deserialize({ 29 | sessionId, 30 | userId, 31 | email, 32 | isSignUp, 33 | requestedAt, 34 | confirmedAt, 35 | }: SessionModels.GoogleSignInConfirmedData) { 36 | return new GoogleSignInConfirmed( 37 | SessionId.fromString(sessionId), 38 | UserId.fromString(userId), 39 | Email.fromString(email), 40 | isSignUp, 41 | Timestamp.fromString(requestedAt), 42 | Timestamp.fromString(confirmedAt), 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/angular-bazel/rxjs_shims.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright Google Inc. All Rights Reserved. 4 | * 5 | * Use of this source code is governed by an MIT-style license that can be 6 | * found in the LICENSE file at https://angular.io/license 7 | */ 8 | 9 | /** 10 | * @fileoverview these provide named UMD modules so that we can bundle 11 | * the application along with rxjs using the concatjs bundler. 12 | */ 13 | 14 | // rxjs/operators 15 | (function (factory) { 16 | if (typeof module === 'object' && typeof module.exports === 'object') { 17 | var v = factory(require, exports); 18 | if (v !== undefined) module.exports = v; 19 | } else if (typeof define === 'function' && define.amd) { 20 | define('rxjs/operators', ['exports', 'rxjs'], factory); 21 | } 22 | })(function (exports, rxjs) { 23 | 'use strict'; 24 | Object.keys(rxjs.operators).forEach(function (key) { 25 | exports[key] = rxjs.operators[key]; 26 | }); 27 | Object.defineProperty(exports, '__esModule', {value: true}); 28 | }); 29 | 30 | // rxjs/testing 31 | (function (factory) { 32 | if (typeof module === 'object' && typeof module.exports === 'object') { 33 | var v = factory(require, exports); 34 | if (v !== undefined) module.exports = v; 35 | } else if (typeof define === 'function' && define.amd) { 36 | define('rxjs/testing', ['exports', 'rxjs'], factory); 37 | } 38 | })(function (exports, rxjs) { 39 | 'use strict'; 40 | Object.keys(rxjs.testing).forEach(function (key) { 41 | exports[key] = rxjs.testing[key]; 42 | }); 43 | Object.defineProperty(exports, '__esModule', {value: true}); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/schemas/authentication/authentication-commands.proto: -------------------------------------------------------------------------------- 1 | import "google/protobuf/empty.proto"; 2 | 3 | import "authentication-events.proto"; 4 | import "../common/common.proto"; 5 | 6 | syntax = "proto3"; 7 | 8 | package authentication; 9 | 10 | message RequestEmailSignIn { 11 | string email = 1; 12 | } 13 | 14 | message ConfirmEmailSignIn { 15 | string signInToken = 1; 16 | } 17 | 18 | message GoogleSignIn { 19 | string code = 1; 20 | } 21 | 22 | message RefreshTokens { 23 | string refreshToken = 1; 24 | } 25 | 26 | message SignOut { 27 | string refreshToken = 1; 28 | } 29 | 30 | message RevokeRefreshToken { 31 | string sessionId = 1; 32 | } 33 | 34 | message AuthTokenResponse { 35 | string refreshToken = 1; 36 | string accessToken = 2; 37 | string userId = 3; 38 | } 39 | 40 | message GoogleLoginUrl { 41 | string url = 1; 42 | } 43 | 44 | message GetEventsByUserId { 45 | string userId = 1; 46 | } 47 | 48 | service AuthenticationCommands { 49 | rpc requestEmailSignIn(RequestEmailSignIn) returns (google.protobuf.Empty); 50 | rpc confirmEmailSignIn(ConfirmEmailSignIn) returns (AuthTokenResponse); 51 | rpc googleSignInUrl(google.protobuf.Empty) returns (GoogleLoginUrl); 52 | rpc googleSignIn(GoogleSignIn) returns (AuthTokenResponse); 53 | rpc refreshToken(RefreshTokens) returns (AuthTokenResponse); 54 | rpc signOut(SignOut) returns (google.protobuf.Empty); 55 | rpc revokeRefreshToken(RevokeRefreshToken) returns (google.protobuf.Empty); 56 | rpc getEventsByUserId(GetEventsByUserId) returns (AuthenticationEvents); 57 | 58 | rpc getEvents(GetEvents) returns (AuthenticationEvents); 59 | } 60 | -------------------------------------------------------------------------------- /packages/types/event-name.ts: -------------------------------------------------------------------------------- 1 | import {Exception} from '@centsideas/utils'; 2 | import {RpcStatus, EventSourcingErrorNames, EventTopics} from '@centsideas/enums'; 3 | 4 | export class EventName { 5 | private regex = new RegExp(/^[A-Z]+$/i); 6 | 7 | constructor( 8 | public readonly name: string, 9 | public readonly aggregate: string, 10 | public readonly service?: string, 11 | ) { 12 | if (!this.regex.exec(name)) throw new EventNameInvalid(name); 13 | if (!this.regex.exec(aggregate)) throw new EventNameInvalid(aggregate); 14 | if (service && !this.regex.exec(service)) throw new EventNameInvalid(service); 15 | } 16 | 17 | static fromString(rawName: string) { 18 | const [namespace, name] = rawName.split(':'); 19 | const aggregate = namespace.includes('.') 20 | ? namespace.substring(namespace.lastIndexOf('.') + 1, namespace.length) 21 | : namespace; 22 | const service = namespace.includes('.') 23 | ? namespace.substring(0, namespace.indexOf('.')) 24 | : undefined; 25 | return new EventName(name, aggregate, service); 26 | } 27 | 28 | toString() { 29 | return this.service 30 | ? `${this.service}.${this.aggregate}:${this.name}` 31 | : `${this.aggregate}:${this.name}`; 32 | } 33 | 34 | getTopic() { 35 | return `centsideas.events.${this.aggregate}` as EventTopics; 36 | } 37 | } 38 | 39 | export class EventNameInvalid extends Exception { 40 | code = RpcStatus.INVALID_ARGUMENT; 41 | name = EventSourcingErrorNames.InvalidEventName; 42 | 43 | constructor(invalidName: string) { 44 | super(`Event name ${invalidName} is invalid.`); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /services/gateway/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 3 | 4 | import 'reflect-metadata'; 5 | import {Logger} from '@centsideas/utils'; 6 | import {DI} from '@centsideas/dependency-injection'; 7 | import {RpcClient, RPC_CLIENT_FACTORY, rpcClientFactory} from '@centsideas/rpc'; 8 | import {GlobalConfig, SecretsConfig} from '@centsideas/config'; 9 | 10 | import {GatewayServer} from './gateway.server'; 11 | import {RootController} from './root.controller'; 12 | import {AuthMiddleware, OptionalAuthMiddleware} from './auth.middleware'; 13 | import {GatewayConfig} from './gateway.config'; 14 | import {AuthenticationController} from './authentication.controller'; 15 | import {IdeaController} from './idea.controller'; 16 | import {UserController} from './user.controller'; 17 | import {PersonalDataController} from './personal-data.controller'; 18 | import {SearchController} from './search.controller'; 19 | import {ReviewController} from './review.controller'; 20 | 21 | DI.registerProviders( 22 | GatewayServer, 23 | RootController, 24 | AuthenticationController, 25 | IdeaController, 26 | AuthMiddleware, 27 | OptionalAuthMiddleware, 28 | UserController, 29 | PersonalDataController, 30 | SearchController, 31 | ReviewController, 32 | ); 33 | DI.registerSingletons(Logger, GatewayConfig, GlobalConfig, SecretsConfig); 34 | 35 | DI.registerProviders(RpcClient); 36 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 37 | 38 | DI.bootstrap(GatewayServer); 39 | 40 | // FIXME handle process.on('uncaughtException') in all node services?! 41 | -------------------------------------------------------------------------------- /packages/schemas/authentication/authentication-events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package authentication; 4 | 5 | message AuthenticationEvents { 6 | repeated AuthenticationEvent events = 1; 7 | } 8 | 9 | message AuthenticationEvent { 10 | string id = 1; 11 | string streamId = 2; 12 | int32 version = 3; 13 | string name = 4; 14 | string insertedAt = 5; 15 | int32 sequence = 6; 16 | AuthenticationEventData data = 7; 17 | } 18 | 19 | message AuthenticationEventData { 20 | oneof data { 21 | SignInRequested signInRequested = 1; 22 | SignInConfirmed signInConfirmed = 2; 23 | SignedOut signedOut = 3; 24 | TokensRefreshed tokensRefreshed = 4; 25 | RefreshTokenRevoked refreshTokenRevoked = 5; 26 | GoogleSignInConfirmed googleSignInConfirmed = 6; 27 | } 28 | } 29 | 30 | message SignInRequestedData { 31 | string sessionId = 1; 32 | string method = 2; 33 | string email = 3; 34 | string requestedAt = 4; 35 | } 36 | 37 | message SignInRequested { 38 | string sessionId = 1; 39 | string method = 2; 40 | string email = 3; 41 | string requestedAt = 4; 42 | SignInRequestedData data = 5; 43 | } 44 | 45 | message SignInConfirmed { 46 | bool isSignUp = 1; 47 | string userId = 2; 48 | string email = 3; 49 | string confirmedAt = 4; 50 | } 51 | 52 | message SignedOut { 53 | string signedOutAt = 1; 54 | } 55 | 56 | message GoogleSignInConfirmed { 57 | string sessionId = 1; 58 | string userId = 2; 59 | string email = 3; 60 | bool isSignUp = 4; 61 | string requestedAt = 5; 62 | string confirmedAt = 6; 63 | } 64 | 65 | message TokensRefreshed {} 66 | 67 | message RefreshTokenRevoked {} 68 | -------------------------------------------------------------------------------- /services/idea/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | // tslint:disable-next-line:no-var-requires 4 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 5 | 6 | import { 7 | EventDispatcher, 8 | MongoEventStore, 9 | MONGO_EVENT_STORE_FACTORY, 10 | mongoEventStoreFactory, 11 | MONGO_SNAPSHOT_STORE_FACTORY, 12 | mongoSnapshotStoreFactory, 13 | MongoSnapshotStore, 14 | } from '@centsideas/event-sourcing'; 15 | import {DI} from '@centsideas/dependency-injection'; 16 | import {Logger} from '@centsideas/utils'; 17 | import { 18 | RPC_SERVER_FACTORY, 19 | rpcServerFactory, 20 | RpcServer, 21 | RPC_CLIENT_FACTORY, 22 | rpcClientFactory, 23 | RpcClient, 24 | } from '@centsideas/rpc'; 25 | import {GlobalConfig} from '@centsideas/config'; 26 | 27 | import {IdeaServer} from './idea.server'; 28 | import {IdeaService} from './idea.service'; 29 | import {IdeaConfig} from './idea.config'; 30 | import {IdeaReadAdapter} from './idea-read.adapter'; 31 | import {UserReadAdapter} from './user-read.adapter'; 32 | 33 | DI.registerProviders(IdeaServer, IdeaService, IdeaReadAdapter, UserReadAdapter); 34 | DI.registerSingletons(IdeaConfig); 35 | 36 | DI.registerSingletons(Logger, GlobalConfig); 37 | DI.registerProviders(RpcServer, RpcClient); 38 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 39 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 40 | DI.registerProviders(EventDispatcher, MongoEventStore, MongoSnapshotStore); 41 | DI.registerFactory(MONGO_EVENT_STORE_FACTORY, mongoEventStoreFactory); 42 | DI.registerFactory(MONGO_SNAPSHOT_STORE_FACTORY, mongoSnapshotStoreFactory); 43 | 44 | DI.bootstrap(IdeaServer); 45 | -------------------------------------------------------------------------------- /services/admin/admin.server.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import {Kafka, ITopicConfig, Admin} from 'kafkajs'; 3 | 4 | import {Logger, ServiceServer} from '@centsideas/utils'; 5 | import {GlobalConfig} from '@centsideas/config'; 6 | import {EventTopics} from '@centsideas/enums'; 7 | 8 | @injectable() 9 | export class AdminServer extends ServiceServer { 10 | private error = null; 11 | private admin: Admin | undefined; 12 | 13 | constructor(private logger: Logger, private globalConfig: GlobalConfig) { 14 | super(); 15 | this.createTopics(); 16 | } 17 | 18 | async createTopics() { 19 | try { 20 | this.logger.info('creating kafka topics...'); 21 | 22 | const kafka = new Kafka({ 23 | clientId: 'centsideas.admin', 24 | brokers: this.globalConfig.getArray('global.kafka.brokers'), 25 | }); 26 | 27 | this.admin = kafka.admin(); 28 | await this.admin.connect(); 29 | 30 | const topics = Object.values(EventTopics).map(t => t.toString()); 31 | const topicsConfig: ITopicConfig[] = topics.map(t => ({topic: t, numPartitions: 1})); 32 | const result = await this.admin.createTopics({topics: topicsConfig}); 33 | this.logger.info(topicsConfig, result ? 'created' : 'were already created'); 34 | 35 | await this.admin.disconnect(); 36 | } catch (error) { 37 | this.logger.warn('failed to create kafka topics'); 38 | this.logger.error(error); 39 | this.error = error; 40 | } 41 | } 42 | 43 | async healthcheck() { 44 | return !!this.error; 45 | } 46 | 47 | async shutdownHandler() { 48 | if (this.admin) await this.admin.disconnect(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | import * as fromChalk from 'chalk'; 3 | import {injectable} from 'inversify'; 4 | 5 | import {Environments, RpcStatus} from '@centsideas/enums'; 6 | 7 | import {GlobalConfig} from '@centsideas/config'; 8 | 9 | // FIXME log persistence in production mode 10 | // FIXME use logger more effectively 11 | @injectable() 12 | export class Logger { 13 | private env = this.globalConfig.get('global.environment'); 14 | private chalk = new fromChalk.Instance({level: this.env === Environments.Prod ? 1 : 3}); 15 | 16 | /** 17 | * Logger should only have dependencies, which do not have dependencies 18 | * themselve! Otherwise we will run into circular dependency issues. 19 | */ 20 | constructor(private globalConfig: GlobalConfig) {} 21 | 22 | error(error: any) { 23 | console.log(this.chalk.red.bold(`error: ${error.name}`)); 24 | const code = error.code || RpcStatus.UNKNOWN; 25 | 26 | if (code === RpcStatus.UNKNOWN || !error.name || error.name === 'Error') { 27 | console.log(this.chalk.red.bold('\nunexpected error')); 28 | console.log(this.chalk.redBright(error.message)); 29 | if (error.details) console.log(this.chalk.red(`details: ${error.details}`)); 30 | if (error.service) console.log(this.chalk.red(`service: ${error.service}`)); 31 | console.log(this.chalk.red(error.stack)); 32 | console.log(this.chalk.red.bold('\n')); 33 | console.log(this.chalk.red.bold('\n')); 34 | } 35 | } 36 | 37 | info(...text: unknown[]) { 38 | console.log(...text); 39 | } 40 | 41 | warn(...text: unknown[]) { 42 | console.warn(this.chalk.yellow.bold(...text)); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /services/review/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | // tslint:disable-next-line:no-var-requires 4 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 5 | 6 | import { 7 | EventDispatcher, 8 | MongoEventStore, 9 | MONGO_EVENT_STORE_FACTORY, 10 | mongoEventStoreFactory, 11 | MONGO_SNAPSHOT_STORE_FACTORY, 12 | mongoSnapshotStoreFactory, 13 | MongoSnapshotStore, 14 | } from '@centsideas/event-sourcing'; 15 | import {DI} from '@centsideas/dependency-injection'; 16 | import {Logger} from '@centsideas/utils'; 17 | import { 18 | RPC_SERVER_FACTORY, 19 | rpcServerFactory, 20 | RpcServer, 21 | RPC_CLIENT_FACTORY, 22 | rpcClientFactory, 23 | RpcClient, 24 | } from '@centsideas/rpc'; 25 | import {GlobalConfig} from '@centsideas/config'; 26 | 27 | import {ReviewServer} from './review.server'; 28 | import {ReviewService} from './review.service'; 29 | import {ReviewConfig} from './review.config'; 30 | import {ReviewReadAdapter} from './review-read.adapter'; 31 | import {IdeaReadAdapter} from './idea-read.adapter'; 32 | 33 | DI.registerProviders(ReviewServer, ReviewService); 34 | DI.registerSingletons(ReviewConfig); 35 | 36 | DI.registerSingletons(Logger, GlobalConfig); 37 | DI.registerProviders(RpcServer, RpcClient, ReviewReadAdapter, IdeaReadAdapter); 38 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 39 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 40 | DI.registerProviders(EventDispatcher, MongoEventStore, MongoSnapshotStore); 41 | DI.registerFactory(MONGO_EVENT_STORE_FACTORY, mongoEventStoreFactory); 42 | DI.registerFactory(MONGO_SNAPSHOT_STORE_FACTORY, mongoSnapshotStoreFactory); 43 | 44 | DI.bootstrap(ReviewServer); 45 | -------------------------------------------------------------------------------- /packages/utils/service-server.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'inversify'; 2 | import * as http from 'http'; 3 | 4 | import {Logger} from './logger'; 5 | 6 | export abstract class ServiceServer { 7 | abstract shutdownHandler(): Promise; 8 | abstract healthcheck(): Promise; 9 | 10 | private errorTypes = ['unhandledRejection', 'uncaughtException']; 11 | private signalTraps = ['SIGTERM', 'SIGINT', 'SIGUSR2']; 12 | 13 | @inject(Logger) private _logger!: Logger; 14 | 15 | constructor(private readonly port: number = 3000) { 16 | http 17 | .createServer(async (_req, res) => { 18 | const alive = await this.healthcheck(); 19 | res.writeHead(alive ? 200 : 500).end(); 20 | }) 21 | .listen(this.port); 22 | 23 | this.errorTypes.forEach(type => { 24 | process.on(type, async error => { 25 | this._logger.warn('handle exit:', type); 26 | this._logger.error(error); 27 | await this.gracefulShutdown(); 28 | }); 29 | }); 30 | 31 | this.signalTraps.forEach(type => { 32 | process.on(type, async () => { 33 | this._logger.warn('handle exit:', type); 34 | await this.gracefulShutdown(); 35 | }); 36 | }); 37 | } 38 | 39 | private async gracefulShutdown() { 40 | try { 41 | this._logger.warn('start server shutdown process'); 42 | await this.shutdownHandler(); 43 | this._logger.info('successfully ran shutdown handler'); 44 | this._logger.info('bye'); 45 | process.exit(0); 46 | } catch (err) { 47 | this._logger.warn('failed to run shutdown handler'); 48 | this._logger.error(err); 49 | this._logger.info('bye'); 50 | process.exit(1); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/event-sourcing/events-handler.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject, postConstruct} from 'inversify'; 2 | 3 | import {PersistedEvent} from '@centsideas/models'; 4 | import {Logger} from '@centsideas/utils'; 5 | 6 | import {EventListener} from './event-bus'; 7 | import {EVENTS_HANDLER_TOPICS} from './event-handler'; 8 | 9 | @injectable() 10 | export abstract class EventsHandler { 11 | @inject(EventListener) private eventListener!: EventListener; 12 | @inject(Logger) private logger!: Logger; 13 | 14 | abstract consumerGroupName: string; 15 | 16 | @postConstruct() 17 | initialize() { 18 | const topics: string[] = Reflect.getMetadata(EVENTS_HANDLER_TOPICS, this); 19 | const topicsRegex = new RegExp(topics.join('|')); 20 | this.eventListener 21 | .listen(topicsRegex, this.consumerGroupName) 22 | // needs to be called via `=>` because `this` will change otherwise 23 | .subscribe(e => this.handleEvent(e)); 24 | } 25 | 26 | get connected() { 27 | return this.eventListener.connected; 28 | } 29 | 30 | async disconnect() { 31 | await this.eventListener.disconnect(); 32 | } 33 | 34 | private async handleEvent(event: PersistedEvent) { 35 | const handlerMethodName = Reflect.getMetadata(event.name, this); 36 | if (!handlerMethodName) return; 37 | if (!(this as any)[handlerMethodName]) 38 | throw new Error(`No handler for event ${event.name} found!`); 39 | 40 | try { 41 | await (this as any)[handlerMethodName](event); 42 | } catch (error) { 43 | // FIXME handle errors that occured in event handlers?! 44 | error.message = `Error in event handler ${event.name}: ${error.message}`; 45 | this.logger.error(error); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /services/user-read/user.repository.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import {MongoClient} from 'mongodb'; 3 | import * as asyncRetry from 'async-retry'; 4 | 5 | import {UserId, Username} from '@centsideas/types'; 6 | import {UserModels} from '@centsideas/models'; 7 | 8 | import {UserReadConfig} from './user-read.config'; 9 | import * as Errors from './user-read.errors'; 10 | 11 | @injectable() 12 | export class UserRepository { 13 | private client = new MongoClient(this.config.get('user-read.database.url'), { 14 | useNewUrlParser: true, 15 | useUnifiedTopology: true, 16 | }); 17 | 18 | constructor(private config: UserReadConfig) {} 19 | 20 | async getById(id: UserId) { 21 | const collection = await this.collection(); 22 | const user = await collection.findOne({id: id.toString()}); 23 | if (!user) throw new Errors.UserNotFound(id); 24 | return user; 25 | } 26 | 27 | async getByUsername(username: Username) { 28 | const collection = await this.collection(); 29 | const user = await collection.findOne({username: username.toString()}); 30 | if (!user) throw new Errors.UserNotFound(); 31 | return user; 32 | } 33 | 34 | async getAll() { 35 | const collection = await this.collection(); 36 | const result = await collection.find({}); 37 | return result.toArray(); 38 | } 39 | 40 | private async collection() { 41 | const db = await this.db(); 42 | return db.collection(this.config.get('user-read.database.collection')); 43 | } 44 | 45 | private async db() { 46 | if (!this.client.isConnected()) await asyncRetry(() => this.client?.connect()); 47 | return this.client.db(this.config.get('user-read.database.name')); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/types/event-name.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import {EventName, EventNameInvalid} from './event-name'; 4 | 5 | describe('EventName', () => { 6 | it('can be created from a string', () => { 7 | const name1 = EventName.fromString('aggregate:eventName'); 8 | expect(name1.aggregate).toEqual('aggregate'); 9 | expect(name1.name).toEqual('eventName'); 10 | 11 | const name2 = EventName.fromString('service.aggregate:eventName'); 12 | expect(name2.aggregate).toEqual('aggregate'); 13 | expect(name2.name).toEqual('eventName'); 14 | expect(name2.service).toEqual('service'); 15 | }); 16 | 17 | it('converts to string', () => { 18 | const name1 = new EventName('eventName', 'aggregate'); 19 | expect(name1.toString()).toEqual('aggregate:eventName'); 20 | 21 | const name2 = new EventName('eventName', 'aggregate', 'service'); 22 | expect(name2.toString()).toEqual('service.aggregate:eventName'); 23 | }); 24 | 25 | it('detects wrong inputs', () => { 26 | expect(() => new EventName('no.dots', 'aggregate')).toThrowError( 27 | new EventNameInvalid('no.dots'), 28 | ); 29 | expect(() => new EventName('no:columns', 'aggregate')).toThrowError( 30 | new EventNameInvalid('no:columns'), 31 | ); 32 | expect(() => new EventName('alright', ':')).toThrowError(new EventNameInvalid(':')); 33 | expect(() => new EventName('alright', '.')).toThrowError(new EventNameInvalid('.')); 34 | expect(() => new EventName('alright', 'ok', ':')).toThrowError(new EventNameInvalid(':')); 35 | expect(() => new EventName('alright', 'ok', '.')).toThrowError(new EventNameInvalid('.')); 36 | expect(() => new EventName('', 'ok')).toThrowError(new EventNameInvalid('')); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /services/user-read/user-read.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: user-read-deployment 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: user-read 9 | replicas: 2 10 | template: 11 | metadata: 12 | labels: 13 | app: user-read 14 | spec: 15 | containers: 16 | - name: user-read 17 | image: user-read:placeholder_name 18 | imagePullPolicy: Always 19 | envFrom: 20 | - configMapRef: 21 | name: global-config 22 | - configMapRef: 23 | name: user-read-config 24 | readinessProbe: 25 | httpGet: 26 | path: / 27 | port: 3000 28 | initialDelaySeconds: 5 29 | periodSeconds: 5 30 | livenessProbe: 31 | httpGet: 32 | path: / 33 | port: 3000 34 | initialDelaySeconds: 15 35 | periodSeconds: 10 36 | resources: 37 | requests: 38 | memory: 10Mi 39 | cpu: 10m 40 | limits: 41 | memory: 500Mi 42 | cpu: 500m 43 | --- 44 | apiVersion: v1 45 | kind: Service 46 | metadata: 47 | name: user-read-service 48 | spec: 49 | selector: 50 | app: user-read 51 | ports: 52 | - name: http 53 | port: 3000 54 | - name: grpc 55 | port: 40000 56 | type: ClusterIP 57 | --- 58 | apiVersion: autoscaling/v1 59 | kind: HorizontalPodAutoscaler 60 | metadata: 61 | name: user-read-hpa 62 | spec: 63 | scaleTargetRef: 64 | apiVersion: apps/v1beta1 65 | kind: Deployment 66 | name: user-read-deployment 67 | minReplicas: 2 68 | maxReplicas: 5 69 | targetCPUUtilizationPercentage: 75 70 | -------------------------------------------------------------------------------- /services/user/index.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | // tslint:disable-next-line:no-var-requires 4 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 5 | 6 | import { 7 | EventsHandler, 8 | EventListener, 9 | MongoEventStore, 10 | EventDispatcher, 11 | MongoSnapshotStore, 12 | MONGO_SNAPSHOT_STORE_FACTORY, 13 | MONGO_EVENT_STORE_FACTORY, 14 | mongoEventStoreFactory, 15 | mongoSnapshotStoreFactory, 16 | } from '@centsideas/event-sourcing'; 17 | import {DI} from '@centsideas/dependency-injection'; 18 | import {Logger} from '@centsideas/utils'; 19 | import {GlobalConfig, SecretsConfig} from '@centsideas/config'; 20 | import { 21 | RPC_SERVER_FACTORY, 22 | rpcServerFactory, 23 | RpcServer, 24 | RpcClient, 25 | RPC_CLIENT_FACTORY, 26 | rpcClientFactory, 27 | } from '@centsideas/rpc'; 28 | 29 | import {UserServer} from './user.server'; 30 | import {UserConfig} from './user.config'; 31 | import {UserService} from './user.service'; 32 | import {UserReadAdapter} from './user-read.adapter'; 33 | import {UserListener} from './user.listener'; 34 | 35 | DI.registerProviders(UserServer, UserService, UserReadAdapter, UserListener); 36 | DI.registerSingletons(UserConfig, GlobalConfig, SecretsConfig); 37 | 38 | DI.registerSingletons(Logger); 39 | DI.registerProviders(EventsHandler); 40 | DI.registerProviders( 41 | EventListener, 42 | EventDispatcher, 43 | MongoEventStore, 44 | MongoSnapshotStore, 45 | RpcServer, 46 | RpcClient, 47 | ); 48 | DI.registerFactory(MONGO_EVENT_STORE_FACTORY, mongoEventStoreFactory); 49 | DI.registerFactory(MONGO_SNAPSHOT_STORE_FACTORY, mongoSnapshotStoreFactory); 50 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 51 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 52 | 53 | DI.bootstrap(UserServer); 54 | -------------------------------------------------------------------------------- /packages/angular-bazel/tslint-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "array-type": false, 5 | "arrow-parens": false, 6 | "deprecation": { 7 | "severity": "warning" 8 | }, 9 | "component-class-suffix": [true, "Container", "Component"], 10 | "contextual-lifecycle": true, 11 | "directive-class-suffix": true, 12 | "import-blacklist": [true, "rxjs/Rx"], 13 | "interface-name": false, 14 | "max-classes-per-file": false, 15 | "max-line-length": [true, 140], 16 | "member-access": false, 17 | "member-ordering": [ 18 | true, 19 | { 20 | "order": ["static-field", "instance-field", "static-method", "instance-method"] 21 | } 22 | ], 23 | "no-consecutive-blank-lines": false, 24 | "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], 25 | "no-empty": false, 26 | "no-inferrable-types": [true, "ignore-params"], 27 | "no-non-null-assertion": true, 28 | "no-redundant-jsdoc": true, 29 | "no-switch-case-fall-through": true, 30 | "no-var-requires": false, 31 | "object-literal-key-quotes": [true, "as-needed"], 32 | "object-literal-sort-keys": false, 33 | "ordered-imports": false, 34 | "quotemark": [true, "single"], 35 | "trailing-comma": false, 36 | "no-conflicting-lifecycle": true, 37 | "no-host-metadata-property": true, 38 | "no-input-rename": true, 39 | "no-inputs-metadata-property": true, 40 | "no-output-native": true, 41 | "no-output-on-prefix": true, 42 | "no-output-rename": true, 43 | "no-outputs-metadata-property": true, 44 | "template-banana-in-box": true, 45 | "template-no-negated-async": true, 46 | "use-lifecycle-interface": true, 47 | "use-pipe-transform-interface": true 48 | }, 49 | "rulesDirectory": ["codelyzer"] 50 | } 51 | -------------------------------------------------------------------------------- /services/authentication/index.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line:no-var-requires 2 | if (process.env['global.environment'] === 'dev') require('module-alias/register'); 3 | 4 | import 'reflect-metadata'; 5 | 6 | import {DI} from '@centsideas/dependency-injection'; 7 | import {Logger} from '@centsideas/utils'; 8 | import {GlobalConfig, SecretsConfig} from '@centsideas/config'; 9 | import { 10 | RpcServer, 11 | RPC_SERVER_FACTORY, 12 | rpcServerFactory, 13 | RpcClient, 14 | RPC_CLIENT_FACTORY, 15 | rpcClientFactory, 16 | } from '@centsideas/rpc'; 17 | import { 18 | EventDispatcher, 19 | MongoEventStore, 20 | MongoSnapshotStore, 21 | MONGO_EVENT_STORE_FACTORY, 22 | mongoEventStoreFactory, 23 | MONGO_SNAPSHOT_STORE_FACTORY, 24 | mongoSnapshotStoreFactory, 25 | } from '@centsideas/event-sourcing'; 26 | 27 | import {AuthenticationServer} from './authentication.server'; 28 | import {AuthenticationService} from './authentication.service'; 29 | import {AuthenticationConfig} from './authentication.config'; 30 | import {UserReadAdapter} from './user-read.adapter'; 31 | import {GoogleApiAdapter} from './google-api.adapter'; 32 | 33 | DI.registerProviders( 34 | AuthenticationServer, 35 | AuthenticationService, 36 | UserReadAdapter, 37 | GoogleApiAdapter, 38 | ); 39 | DI.registerSingletons(AuthenticationConfig); 40 | 41 | DI.registerSingletons(Logger, GlobalConfig, SecretsConfig); 42 | DI.registerProviders(RpcServer, RpcClient); 43 | DI.registerFactory(RPC_SERVER_FACTORY, rpcServerFactory); 44 | DI.registerFactory(RPC_CLIENT_FACTORY, rpcClientFactory); 45 | DI.registerProviders(EventDispatcher, MongoEventStore, MongoSnapshotStore); 46 | DI.registerFactory(MONGO_EVENT_STORE_FACTORY, mongoEventStoreFactory); 47 | DI.registerFactory(MONGO_SNAPSHOT_STORE_FACTORY, mongoSnapshotStoreFactory); 48 | 49 | DI.bootstrap(AuthenticationServer); 50 | -------------------------------------------------------------------------------- /packages/schemas/user/user-events.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package user; 4 | 5 | message UserEvents { 6 | repeated UserEvent events = 1; 7 | } 8 | 9 | message PrivateUserEvents { 10 | repeated PrivateUserEvent events = 1; 11 | } 12 | 13 | message UserEvent { 14 | string id = 1; 15 | string streamId = 2; 16 | int32 version = 3; 17 | string name = 4; 18 | string insertedAt = 5; 19 | int32 sequence = 6; 20 | UserEventData data = 7; 21 | } 22 | 23 | message PrivateUserEvent { 24 | string id = 1; 25 | string streamId = 2; 26 | int32 version = 3; 27 | string name = 4; 28 | string insertedAt = 5; 29 | int32 sequence = 6; 30 | PrivateUserEventData data = 7; 31 | } 32 | 33 | message UserEventData { 34 | oneof data { 35 | UserCreatedEvent created = 1; 36 | RenamedEvent renamed = 2; 37 | DeletionRequestedEvent deletionRequested = 3; 38 | DeletionConfirmedEvent deletionConfirmed = 4; 39 | } 40 | } 41 | 42 | message PrivateUserEventData { 43 | oneof data { 44 | PrivateUserCreatedEvent created = 1; 45 | EmailChangeRequestedEvent emailChangeRequested = 2; 46 | EmailChangeConfirmedEvent emailChangeConfirmed = 3; 47 | PrivateUserDeletedEvent deleted = 4; 48 | } 49 | } 50 | 51 | message UserCreatedEvent { 52 | string id = 1; 53 | string username = 2; 54 | string createdAt = 3; 55 | } 56 | 57 | message RenamedEvent { 58 | string username = 1; 59 | } 60 | 61 | message DeletionRequestedEvent { 62 | string requestedAt = 1; 63 | } 64 | 65 | message DeletionConfirmedEvent { 66 | string deletedAt = 1; 67 | } 68 | 69 | message PrivateUserCreatedEvent { 70 | string id = 1; 71 | string email = 2; 72 | } 73 | 74 | message EmailChangeRequestedEvent { 75 | string newEmail = 1; 76 | } 77 | 78 | message EmailChangeConfirmedEvent {} 79 | 80 | message PrivateUserDeletedEvent { 81 | string deletedAt = 1; 82 | } -------------------------------------------------------------------------------- /services/user-read/private-user.repository.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import {MongoClient} from 'mongodb'; 3 | import * as asyncRetry from 'async-retry'; 4 | 5 | import {UserId, Email} from '@centsideas/types'; 6 | import {UserModels} from '@centsideas/models'; 7 | 8 | import {UserReadConfig} from './user-read.config'; 9 | import * as Errors from './user-read.errors'; 10 | 11 | @injectable() 12 | export class PrivateUserRepository { 13 | private client = new MongoClient(this.config.get('user-read.private_database.url'), { 14 | useNewUrlParser: true, 15 | useUnifiedTopology: true, 16 | }); 17 | 18 | constructor(private config: UserReadConfig) {} 19 | 20 | async getById(id: UserId) { 21 | const collection = await this.collection(); 22 | const privateUser = await collection.findOne({id: id.toString()}); 23 | if (!privateUser) throw new Errors.UserNotFound(id); 24 | return privateUser; 25 | } 26 | 27 | async getEmailById(id: UserId) { 28 | const collection = await this.collection(); 29 | const user = await collection.findOne({id: id.toString()}); 30 | if (!user) throw new Errors.UserNotFound(id); 31 | return user; 32 | } 33 | 34 | async getByEmail(email: Email) { 35 | const collection = await this.collection(); 36 | const privateUser = await collection.findOne({email: email.toString()}); 37 | if (!privateUser) throw new Errors.UserNotFound(); 38 | return privateUser; 39 | } 40 | 41 | private async collection() { 42 | const db = await this.db(); 43 | return db.collection( 44 | this.config.get('user-read.private_database.collection'), 45 | ); 46 | } 47 | 48 | private async db() { 49 | if (!this.client.isConnected()) await asyncRetry(() => this.client?.connect()); 50 | return this.client.db(this.config.get('user-read.private_database.name')); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /services/idea/idea-tags.ts: -------------------------------------------------------------------------------- 1 | import * as sanitize from 'sanitize-html'; 2 | 3 | import {IdeaTagsCount, IdeaTagsLength} from '@centsideas/enums'; 4 | 5 | import * as Errors from './idea.errors'; 6 | 7 | export class IdeaTags { 8 | constructor(private tags: string[] = []) { 9 | if (tags.length > IdeaTagsCount.Max) throw new Errors.TooManyIdeaTags(tags); 10 | this.tags = this.cleanTags(this.tags); 11 | tags.forEach(this.validateTag); 12 | } 13 | 14 | static empty() { 15 | return new IdeaTags([]); 16 | } 17 | 18 | static fromArray(tags: string[]) { 19 | return new IdeaTags(tags); 20 | } 21 | 22 | add(toAdd: IdeaTags) { 23 | const updatedTags = [...this.tags, ...toAdd.toArray()]; 24 | const cleaned = this.cleanTags(updatedTags); 25 | cleaned.forEach(this.validateTag); 26 | if (cleaned.length > IdeaTagsCount.Max) throw new Errors.TooManyIdeaTags(cleaned); 27 | this.tags = cleaned; 28 | } 29 | 30 | remove(toRemove: IdeaTags) { 31 | const remove = toRemove.toArray(); 32 | this.tags = this.tags.filter(t => !remove.includes(t)); 33 | } 34 | 35 | toArray() { 36 | return this.tags; 37 | } 38 | 39 | findDifference(changedTags: IdeaTags) { 40 | const added = IdeaTags.fromArray(changedTags.tags.filter(t => !this.tags.includes(t))); 41 | const removed = IdeaTags.fromArray(this.tags.filter(t => !changedTags.tags.includes(t))); 42 | return {added, removed}; 43 | } 44 | 45 | private cleanTags(tags: string[]) { 46 | tags = tags.map(t => sanitize(t)); 47 | tags = tags.filter((tag, pos) => tags.indexOf(tag) === pos); 48 | tags = tags.map(t => t.replace(new RegExp(' ', 'g'), '-')); 49 | return tags; 50 | } 51 | 52 | private validateTag(tag: string) { 53 | if (tag.length > IdeaTagsLength.Max) throw new Errors.IdeaTagTooLong(tag); 54 | if (tag.length < IdeaTagsLength.Min) throw new Errors.IdeaTagTooShort(tag); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /services/admin/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@npm//@bazel/typescript:index.bzl", "ts_library") 2 | load("@centsideas//packages/jest:jest.bzl", "ts_jest") 3 | load("@io_bazel_rules_docker//nodejs:image.bzl", "nodejs_image") 4 | load("@k8s_deploy//:defaults.bzl", "k8s_deploy") 5 | load("@microk8s_deploy//:defaults.bzl", "microk8s_deploy") 6 | load("@io_bazel_rules_k8s//k8s:objects.bzl", "k8s_objects") 7 | load("@build_bazel_rules_nodejs//:index.bzl", "pkg_npm") 8 | 9 | package(default_visibility = ["//visibility:public"]) 10 | 11 | ts_library( 12 | name = "lib", 13 | srcs = glob( 14 | include = ["**/*.ts"], 15 | exclude = ["**/*.spec.ts"], 16 | ), 17 | deps = [ 18 | "//packages/config", 19 | "//packages/dependency-injection", 20 | "//packages/enums", 21 | "//packages/utils", 22 | "@npm//@types/node", 23 | "@npm//inversify", 24 | "@npm//kafkajs", 25 | "@npm//reflect-metadata", 26 | ], 27 | ) 28 | 29 | pkg_npm( 30 | name = "pkg", 31 | package_name = "centsideas/services/admin", 32 | deps = [":lib"], 33 | ) 34 | 35 | nodejs_image( 36 | name = "image", 37 | data = [":lib"], 38 | entry_point = ":index.ts", 39 | ) 40 | 41 | ts_jest( 42 | name = "test", 43 | srcs = glob(["**/*.spec.ts"]), 44 | data = [":pkg"], 45 | test_lib = "lib", 46 | tsconfig = "//:tsconfig.json", 47 | deps = [], 48 | ) 49 | 50 | k8s_deploy( 51 | name = "admin", 52 | images = {"admin:placeholder_name": ":image"}, 53 | template = ":admin.yaml", 54 | ) 55 | 56 | k8s_objects( 57 | name = "k8s", 58 | objects = [ 59 | ":admin", 60 | ], 61 | ) 62 | 63 | microk8s_deploy( 64 | name = "microk8s_admin", 65 | images = {"admin:placeholder_name": ":image"}, 66 | template = ":admin.yaml", 67 | ) 68 | 69 | k8s_objects( 70 | name = "microk8s", 71 | objects = [ 72 | ":microk8s_admin", 73 | ], 74 | ) 75 | -------------------------------------------------------------------------------- /packages/types/username.ts: -------------------------------------------------------------------------------- 1 | import {UsernameLength, UsernameRegex, RpcStatus, UserErrorNames} from '@centsideas/enums'; 2 | import {Exception} from '@centsideas/utils'; 3 | 4 | export class Username { 5 | private constructor(private readonly username: string) { 6 | if (!this.username || this.username.length < UsernameLength.Min) 7 | throw new UsernameTooShort(username); 8 | if (this.username.length > UsernameLength.Max) throw new UsernameTooLong(username); 9 | if (!UsernameRegex.test(username)) throw new UsernameInvalid(username); 10 | } 11 | 12 | static fromString(username: string) { 13 | return new Username(username); 14 | } 15 | 16 | toString() { 17 | return this.username; 18 | } 19 | 20 | equals(username: Username) { 21 | return this.username === username.toString(); 22 | } 23 | } 24 | 25 | export class UsernameTooLong extends Exception { 26 | code = RpcStatus.INVALID_ARGUMENT; 27 | name = UserErrorNames.UsernameTooLong; 28 | 29 | constructor(username: string) { 30 | super( 31 | `Username too long. Max length is ${UsernameLength.Max}. ${username} is ${username.length} characters long.`, 32 | {username}, 33 | ); 34 | } 35 | } 36 | 37 | export class UsernameTooShort extends Exception { 38 | code = RpcStatus.INVALID_ARGUMENT; 39 | name = UserErrorNames.UsernameTooShort; 40 | 41 | constructor(username: string) { 42 | super( 43 | `Username too short. Min length is ${UsernameLength.Min}. ${username} is only ${ 44 | username ? username.length : 0 45 | } characters long.`, 46 | {username}, 47 | ); 48 | } 49 | } 50 | 51 | export class UsernameInvalid extends Exception { 52 | code = RpcStatus.INVALID_ARGUMENT; 53 | name = UserErrorNames.UsernameInvalid; 54 | 55 | constructor(username: string) { 56 | super( 57 | `Username ${username} contains invalid characters. "_" and "." are allowed, when not used at start or end.`, 58 | {username}, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /misc/docs/microk8s.md: -------------------------------------------------------------------------------- 1 | # Kubernetes for Local Development 2 | 3 | > ⚠️ This guide is designed to work for Ubuntu 20.04 LTS 4 | 5 | ## 1. Install and Setup [MicroK8s](https://microk8s.io) 6 | 7 | ```bash 8 | # install 9 | sudo snap install microk8s --classic 10 | 11 | # configure sudo permissions 12 | sudo usermod -a -G microk8s $USER && \ 13 | sudo chown -f -R $USER ~/.kube && \ 14 | su - $USER 15 | 16 | # allow local registry 17 | sudo cp misc/daemon.json /etc/docker/daemon.json && \ 18 | sudo systemctl restart docker 19 | 20 | # create aliases 21 | sudo snap alias microk8s.kubectl kubectl && \ 22 | sudo snap alias microk8s.kubectl k 23 | 24 | # start cluster 25 | microk8s start 26 | ``` 27 | 28 | ## 2. Deploy the Application for the First Time 29 | 30 | ```bash 31 | # enbale necessary addons 32 | microk8s enable dns ingress registry 33 | 34 | # setup kafka 35 | kubectl create namespace kafka 36 | kubectl apply -f 'https://strimzi.io/install/latest?namespace=kafka' -n kafka 37 | kubectl apply -f packages/kubernetes/kafka-ephemeral.yaml -n kafka 38 | 39 | # setup mongodb 40 | git clone https://github.com/mongodb/mongodb-kubernetes-operator && \ 41 | cd mongodb-kubernetes-operator && \ 42 | kubectl create namespace mongodb && \ 43 | kubectl create -f deploy/crds/mongodb.com_mongodb_crd.yaml && \ 44 | kubectl create -f deploy/ --namespace mongodb 45 | 46 | cd .. 47 | microk8s.kubectl apply -f packages/kubernetes/event-store.yaml -n mongodb && \ 48 | microk8s.kubectl apply -f packages/kubernetes/read-database.yaml -n mongodb 49 | 50 | # setup elasticsearch 51 | kubectl apply -f https://download.elastic.co/downloads/eck/1.1.2/all-in-one.yaml 52 | kubectl apply -f packages/kubernetes/elasticsearch-local.yaml 53 | 54 | # while everything is spinning up you can configure 55 | # environment variables and secrets in the /env directory 56 | 57 | # deploy services 58 | yarn deploy:microk8s 59 | ``` 60 | 61 | ## 3. Develop 62 | 63 | After you've made your changes, just run 64 | 65 | ```bash 66 | yarn dpeloy:microk8s 67 | ``` 68 | -------------------------------------------------------------------------------- /services/idea/idea.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: idea-deployment 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: idea 9 | replicas: 2 10 | template: 11 | metadata: 12 | labels: 13 | app: idea 14 | spec: 15 | containers: 16 | - name: idea 17 | image: idea:placeholder_name 18 | imagePullPolicy: Always 19 | envFrom: 20 | - configMapRef: 21 | name: global-config 22 | - configMapRef: 23 | name: idea-config 24 | readinessProbe: 25 | httpGet: 26 | path: / 27 | port: 3000 28 | initialDelaySeconds: 5 29 | periodSeconds: 5 30 | livenessProbe: 31 | httpGet: 32 | path: / 33 | port: 3000 34 | initialDelaySeconds: 15 35 | periodSeconds: 10 36 | resources: 37 | requests: 38 | memory: 10Mi 39 | cpu: 10m 40 | limits: 41 | memory: 500Mi 42 | cpu: 500m 43 | affinity: 44 | nodeAffinity: 45 | preferredDuringSchedulingIgnoredDuringExecution: 46 | - preference: 47 | matchExpressions: 48 | - key: cloud.google.com/gke-preemptible 49 | operator: Exists 50 | weight: 100 51 | --- 52 | apiVersion: v1 53 | kind: Service 54 | metadata: 55 | name: idea-service 56 | spec: 57 | selector: 58 | app: idea 59 | ports: 60 | - name: http 61 | port: 3000 62 | - name: grpc 63 | port: 40000 64 | type: ClusterIP 65 | 66 | --- 67 | apiVersion: autoscaling/v1 68 | kind: HorizontalPodAutoscaler 69 | metadata: 70 | name: idea-hpa 71 | spec: 72 | scaleTargetRef: 73 | apiVersion: apps/v1beta1 74 | kind: Deployment 75 | name: idea-deployment 76 | minReplicas: 2 77 | maxReplicas: 5 78 | targetCPUUtilizationPercentage: 75 79 | -------------------------------------------------------------------------------- /services/idea-read/idea-read.server.ts: -------------------------------------------------------------------------------- 1 | import {injectable, inject} from 'inversify'; 2 | 3 | import {RpcServer, RpcServerFactory, RPC_SERVER_FACTORY, RpcMethod} from '@centsideas/rpc'; 4 | import {IdeaReadService, IdeaReadQueries} from '@centsideas/schemas'; 5 | import {IdeaId, UserId} from '@centsideas/types'; 6 | import {ServiceServer} from '@centsideas/utils'; 7 | 8 | import {IdeaRepository} from './idea.repository'; 9 | import {IdeaProjector} from './idea.projector'; 10 | 11 | @injectable() 12 | export class IdeaReadServer extends ServiceServer implements IdeaReadQueries.Service { 13 | private rpcServer: RpcServer = this.rpcServerFactory({ 14 | services: [IdeaReadService], 15 | handlerClassInstance: this, 16 | }); 17 | 18 | constructor( 19 | private repository: IdeaRepository, 20 | private projector: IdeaProjector, 21 | @inject(RPC_SERVER_FACTORY) private rpcServerFactory: RpcServerFactory, 22 | ) { 23 | super(); 24 | } 25 | 26 | @RpcMethod(IdeaReadService) 27 | getById({id, userId}: IdeaReadQueries.GetBydId) { 28 | return this.repository.getById( 29 | IdeaId.fromString(id), 30 | userId ? UserId.fromString(userId) : undefined, 31 | ); 32 | } 33 | 34 | @RpcMethod(IdeaReadService) 35 | async getAll() { 36 | const ideas = await this.repository.getAll(); 37 | return {ideas}; 38 | } 39 | 40 | @RpcMethod(IdeaReadService) 41 | getUnpublished({userId}: IdeaReadQueries.GetUnpublished) { 42 | return this.repository.getUnpublished(UserId.fromString(userId)); 43 | } 44 | 45 | @RpcMethod(IdeaReadService) 46 | async getAllByUserId({userId, privates}: IdeaReadQueries.GetAllByUserId) { 47 | const ideas = await this.repository.getAllByUserId(UserId.fromString(userId), privates); 48 | return {ideas}; 49 | } 50 | 51 | async healthcheck() { 52 | return this.rpcServer.isRunning && this.projector.connected; 53 | } 54 | 55 | async shutdownHandler() { 56 | await Promise.all([this.projector.shutdown(), this.rpcServer.disconnect()]); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /services/authentication/google-api.adapter.ts: -------------------------------------------------------------------------------- 1 | import {injectable} from 'inversify'; 2 | import * as queryString from 'query-string'; 3 | import axios from 'axios'; 4 | 5 | import {SecretsConfig, GlobalConfig} from '@centsideas/config'; 6 | 7 | @injectable() 8 | export class GoogleApiAdapter { 9 | private readonly googleClientId = this.secretesConfig.get('secrets.google.client_id'); 10 | private readonly googleClientSecret = this.secretesConfig.get('secrets.google.client_secret'); 11 | private readonly googleRedirectUrl = 12 | this.globalConfig.get('global.api.url') + '/auth/google/signin/token'; // FIXME frontend url evenrually 13 | 14 | constructor(private secretesConfig: SecretsConfig, private globalConfig: GlobalConfig) {} 15 | 16 | get getSignInUrl() { 17 | const params = queryString.stringify({ 18 | client_id: this.secretesConfig.get('secrets.google.client_id'), 19 | redirect_uri: this.googleRedirectUrl, 20 | scope: [ 21 | 'https://www.googleapis.com/auth/userinfo.email', 22 | 'https://www.googleapis.com/auth/userinfo.profile', 23 | ].join(' '), 24 | response_type: 'code', 25 | access_type: 'offline', 26 | prompt: 'consent', 27 | }); 28 | return `https://accounts.google.com/o/oauth2/v2/auth?${params}`; 29 | } 30 | 31 | async getAccessToken(code: string) { 32 | const tokenResponse = await axios.post(`https://oauth2.googleapis.com/token`, { 33 | client_id: this.googleClientId, 34 | client_secret: this.googleClientSecret, 35 | redirect_uri: this.googleRedirectUrl, 36 | grant_type: 'authorization_code', 37 | code, 38 | }); 39 | const {access_token} = tokenResponse.data; 40 | return access_token; 41 | } 42 | 43 | async getUserInfo(accessToken: string) { 44 | const userInfoResponse = await axios.get(`https://www.googleapis.com/oauth2/v2/userinfo`, { 45 | headers: {Authorization: `Bearer ${accessToken}`}, 46 | }); 47 | const {email} = userInfoResponse.data; 48 | return {email}; 49 | } 50 | } 51 | --------------------------------------------------------------------------------