├── logs └── .gitkeep ├── .eslintignore ├── .gitignore ├── src ├── graphql │ ├── resolvers │ │ ├── dataLoaders │ │ │ ├── index.ts │ │ │ └── VotedLoader.ts │ │ ├── Date.ts │ │ ├── index.ts │ │ ├── ConferenceWeek.ts │ │ ├── SearchTerm.ts │ │ ├── Activity.ts │ │ ├── User.ts │ │ ├── Deputy.ts │ │ ├── Device.ts │ │ ├── Vote.ts │ │ └── Procedure.ts │ └── schemas │ │ ├── Schema.ts │ │ ├── Documents.ts │ │ ├── ConferenceWeek.ts │ │ ├── SearchTerms.ts │ │ ├── index.ts │ │ ├── Activity.ts │ │ ├── User.ts │ │ ├── Deputy.ts │ │ ├── Vote.ts │ │ ├── Device.ts │ │ └── Procedure.ts ├── types │ ├── express.d.ts │ ├── rsa.d.ts │ ├── myGlobals.d.ts │ ├── mongodb-migrations.d.ts │ └── graphqlContext.ts ├── services │ ├── search │ │ └── index.ts │ ├── mongoose │ │ └── index.ts │ ├── cronJobs │ │ └── index.ts │ ├── graphql │ │ └── index.ts │ ├── logger │ │ └── index.ts │ └── sms │ │ └── index.ts ├── config │ ├── jwt.ts │ ├── smsverification.ts │ ├── cronjobConfig.ts │ ├── procedureStates.ts │ └── index.ts ├── express │ └── auth │ │ ├── permissions.ts │ │ └── index.ts ├── index.ts ├── data │ └── conference-weeks.ts └── generated │ └── graphql.ts ├── .dockerignore ├── .env.enc ├── .prettierrc.js ├── Dockerfile.dev ├── apollo.config.js ├── codegen.self.yml ├── .eslintrc.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── build-docker.yaml ├── tsconfig.json ├── __generated__ └── globalTypes.ts ├── Dockerfile ├── .env.example ├── package.json ├── README.md ├── CHANGELOG.md └── LICENSE /logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | coverage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.env 3 | *.log 4 | dist/* 5 | AuthKey_*.p8 -------------------------------------------------------------------------------- /src/graphql/resolvers/dataLoaders/index.ts: -------------------------------------------------------------------------------- 1 | export * from './VotedLoader'; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.env 3 | *.log 4 | dist/* 5 | AuthKey_*.p8 6 | logs -------------------------------------------------------------------------------- /.env.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/demokratie-live/democracy-server/HEAD/.env.enc -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | singleQuote: true, 4 | trailingComma: "all" 5 | }; 6 | -------------------------------------------------------------------------------- /src/graphql/schemas/Schema.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | scalar Date 3 | 4 | type Schema { 5 | query: Query 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /src/graphql/schemas/Documents.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Document { 3 | editor: String! 4 | number: String! 5 | type: String! 6 | url: String! 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | import { ExpressReqContext } from './graphqlContext'; 2 | 3 | declare namespace Express { 4 | export type Request = ExpressReqContext 5 | } 6 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine 2 | 3 | WORKDIR /app 4 | COPY package.json yarn.lock ./ 5 | RUN yarn --frozen-lockfile 6 | 7 | COPY . . 8 | 9 | ENV NODE_ENV=development 10 | 11 | ENTRYPOINT [ "yarn", "dev" ] -------------------------------------------------------------------------------- /src/types/rsa.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-native-rsa' { 2 | export default class RSAKey { 3 | setPrivateString = (secretKey: string): void => {}; 4 | 5 | decrypt = (value: string): string => {}; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/graphql/resolvers/Date.ts: -------------------------------------------------------------------------------- 1 | import GraphQLDate from 'graphql-date'; 2 | import { Resolvers } from '../../generated/graphql'; 3 | 4 | const DateApi: Resolvers = { 5 | Date: GraphQLDate, 6 | }; 7 | 8 | export default DateApi; 9 | -------------------------------------------------------------------------------- /src/graphql/schemas/ConferenceWeek.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | type ConferenceWeek { 4 | start: Date! 5 | end: Date! 6 | calendarWeek: Int! 7 | } 8 | 9 | type Query { 10 | currentConferenceWeek: ConferenceWeek! 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /src/types/myGlobals.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | import { Logger } from 'winston'; 3 | 4 | interface Global { 5 | Log: Logger; 6 | } 7 | 8 | interface Process { 9 | NODE_ENV?: 'development' | 'production'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apollo.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | client: { 3 | includes: ['./src/**/*.ts'], 4 | service: { 5 | name: 'Bundestag.io API Local', 6 | url: 'http://localhost:3100', 7 | skipSSLValidation: true, 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/graphql/schemas/SearchTerms.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | type SearchTerm { 4 | term: String! 5 | } 6 | 7 | type Query { 8 | mostSearched: [SearchTerm!]! 9 | } 10 | 11 | type Mutation { 12 | finishSearch(term: String!): SearchTerm! 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /src/graphql/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { fileLoader, mergeTypes } from 'merge-graphql-schemas'; 2 | import path from 'path'; 3 | import _ from 'lodash'; 4 | 5 | const typesArray = _.uniq(fileLoader(path.join(__dirname, './'))); 6 | 7 | export default mergeTypes(typesArray); 8 | -------------------------------------------------------------------------------- /src/graphql/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { fileLoader, mergeResolvers } from 'merge-graphql-schemas'; 3 | 4 | const resolversArray = fileLoader(path.join(__dirname, './'), { extensions: ['.js', '.ts'] }); 5 | 6 | export default mergeResolvers(resolversArray); 7 | -------------------------------------------------------------------------------- /src/graphql/schemas/Activity.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | type ActivityIndex { 4 | activityIndex: Int! 5 | active: Boolean 6 | } 7 | 8 | type Query { 9 | activityIndex(procedureId: String!): ActivityIndex 10 | } 11 | 12 | type Mutation { 13 | increaseActivity(procedureId: String!): ActivityIndex 14 | } 15 | `; 16 | -------------------------------------------------------------------------------- /src/services/search/index.ts: -------------------------------------------------------------------------------- 1 | import elasticsearch from 'elasticsearch'; 2 | 3 | import CONFIG from '../../config'; 4 | 5 | const client = new elasticsearch.Client({ 6 | host: `${CONFIG.ELASTICSEARCH_URL}:9200`, 7 | log: process.NODE_ENV === 'development' ? 'warning' : 'error', 8 | }); 9 | 10 | export default client; 11 | -------------------------------------------------------------------------------- /src/graphql/schemas/User.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | type User { 3 | _id: String! 4 | verified: Boolean! 5 | deviceHash: String @deprecated 6 | } 7 | 8 | type Auth { 9 | token: String! 10 | } 11 | 12 | type Mutation { 13 | signUp(deviceHashEncrypted: String!): Auth 14 | } 15 | 16 | type Query { 17 | me: User 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /src/config/jwt.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | AUTH_JWT_SECRET: process.env.AUTH_JWT_SECRET || '', 3 | AUTH_JWT_TTL: process.env.AUTH_JWT_TTL || '1d', 4 | AUTH_JWT_REFRESH_TTL: process.env.AUTH_JWT_REFRESH_TTL || '7d', 5 | JWT_BACKWARD_COMPATIBILITY: process.env.JWT_BACKWARD_COMPATIBILITY === 'true', 6 | SECRET_KEY: process.env.SECRET_KEY || '', // Old for RSA encrypted signup call // TODO remove me 7 | }; 8 | -------------------------------------------------------------------------------- /src/graphql/resolvers/ConferenceWeek.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentConferenceWeek } from '../../data/conference-weeks'; 2 | import { Resolvers } from '../../generated/graphql'; 3 | 4 | const ConferenceWeekApi: Resolvers = { 5 | Query: { 6 | currentConferenceWeek: async () => { 7 | global.Log.graphql('ConferenceWeek.query.currentConferenceWeek'); 8 | return getCurrentConferenceWeek(); 9 | }, 10 | }, 11 | }; 12 | 13 | export default ConferenceWeekApi; 14 | -------------------------------------------------------------------------------- /src/config/smsverification.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | SMS_VERIFICATION: !(process.env.SMS_VERIFICATION === 'false'), 3 | SMS_VERIFICATION_CODE_TTL: process.env.SMS_VERIFICATION_CODE_TTL || '1d', 4 | SMS_VERIFICATION_CODE_RESEND_BASETIME: 5 | process.env.SMS_VERIFICATION_CODE_RESEND_BASETIME || '120s', 6 | SMS_VERIFICATION_NEW_USER_DELAY: process.env.SMS_VERIFICATION_NEW_USER_DELAY || '24w', 7 | SMS_PROVIDER_KEY: process.env.SMS_PROVIDER_KEY, 8 | SMS_SIMULATE: process.env.SMS_SIMULATE === 'true', 9 | }; 10 | -------------------------------------------------------------------------------- /codegen.self.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: 3 | - 'http://localhost:3000' 4 | documents: null 5 | generates: 6 | src/generated/graphql.ts: 7 | config: 8 | contextType: ../types/graphqlContext#GraphQlContext 9 | defaultMapper: DeepPartial<{T}> 10 | mappers: 11 | Deputy: ../migrations/4-schemas/Deputy#IDeputy 12 | Procedure: ../migrations/11-schemas/Procedure#IProcedure 13 | # Vote: ../migrations/2-schemas/Vote#VoteProps 14 | plugins: 15 | - 'typescript' 16 | - 'typescript-resolvers' 17 | - 'typescript-mongodb' 18 | - add: "import { DeepPartial } from 'utility-types';" 19 | ./graphql.schema.json: 20 | plugins: 21 | - 'introspection' 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | parserOptions: { 6 | project: `./tsconfig.json`, 7 | }, 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | rules: { 15 | '@typescript-eslint/explicit-function-return-type': 0, 16 | '@typescript-eslint/no-explicit-any': 0, 17 | '@typescript-eslint/no-empty-function': 0, 18 | '@typescript-eslint/camelcase': 0, 19 | '@typescript-eslint/interface-name-prefix': 0, 20 | // '@typescript-eslint/no-unnecessary-condition': ['error'], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Pullrequest 2 | 3 | 4 | ### Issues 5 | 9 | - [X] None 10 | 11 | ### Checklist 12 | 16 | - [X] None 17 | 18 | ### How2Test 19 | 20 | 24 | - [X] None 25 | 26 | ### Todo 27 | 31 | - [X] None 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "diagnostics": false, 4 | "outDir": "./dist", 5 | "allowJs": true, 6 | "target": "es5", 7 | "module": "commonjs", 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "lib": ["ESNext"], 11 | // "typeRoots": ["./src/types", "./node_modules/@types"], 12 | "downlevelIteration": true, 13 | "allowUnreachableCode": false, 14 | "allowUnusedLabels": false, 15 | "alwaysStrict": true, 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": false, 19 | "noImplicitAny": true, 20 | "suppressExcessPropertyErrors": false, 21 | "suppressImplicitAnyIndexErrors": false 22 | }, 23 | "include": ["./src/**/*"], 24 | "exclude": ["node_modules", "generated"] 25 | } 26 | -------------------------------------------------------------------------------- /__generated__/globalTypes.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | // @generated 4 | // This file was automatically generated and should not be edited. 5 | 6 | //============================================================== 7 | // START Enums and Input Objects 8 | //============================================================== 9 | 10 | export enum VoteDecision { 11 | ABSTINATION = "ABSTINATION", 12 | NO = "NO", 13 | NOTVOTED = "NOTVOTED", 14 | YES = "YES", 15 | } 16 | 17 | export enum VotingDocument { 18 | mainDocument = "mainDocument", 19 | recommendedDecision = "recommendedDecision", 20 | } 21 | 22 | //============================================================== 23 | // END Enums and Input Objects 24 | //============================================================== 25 | -------------------------------------------------------------------------------- /src/types/mongodb-migrations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'mongodb-migrations' { 2 | import mongoose from 'mongoose'; 3 | 4 | export interface Migrator { 5 | db: mongoose.Connection; 6 | log: Function; 7 | } 8 | 9 | export interface DoneCallback { 10 | (): void; 11 | (error: any): void; 12 | } 13 | 14 | export class Migrator { 15 | constructor({ directory: string }): Migrator {} 16 | 17 | create(path: string, name: string, callback: (err?: Error) => void) {} 18 | 19 | dispose(): void {} 20 | 21 | runFromDir(path: string, callback: (err?: Error) => void) {} 22 | } 23 | 24 | export const id = 'ProposalPrice'; 25 | 26 | export type up = (this: Migrator, done: DoneCallback) => {}; 27 | 28 | export type down = (this: Migrator, done: DoneCallback) => {}; 29 | } 30 | -------------------------------------------------------------------------------- /src/graphql/schemas/Deputy.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | type DeputyLink { 3 | name: String! 4 | URL: String! 5 | username: String 6 | } 7 | 8 | type DeputyContact { 9 | address: String 10 | email: String 11 | links: [DeputyLink!]! 12 | } 13 | 14 | type DeputyProcedure { 15 | decision: VoteSelection! 16 | procedure: Procedure! 17 | } 18 | 19 | type Deputy { 20 | _id: ID! 21 | webId: String! 22 | imgURL: String! 23 | name: String! 24 | party: String 25 | job: String 26 | biography: String 27 | constituency: String 28 | directCandidate: Boolean 29 | contact: DeputyContact 30 | totalProcedures: Int 31 | procedures(procedureIds: [String!], pageSize: Int, offset: Int): [DeputyProcedure!]! 32 | } 33 | 34 | type Query { 35 | deputiesOfConstituency(constituency: String!, directCandidate: Boolean): [Deputy!]! 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine AS BUILD_IMAGE 2 | 3 | # install next-optimized-images requirements 4 | RUN apk --no-cache update \ 5 | && apk --no-cache add yarn curl bash python \ 6 | && rm -fr /var/cache/apk/* 7 | 8 | # install node-prune (https://github.com/tj/node-prune) 9 | RUN curl -sfL https://install.goreleaser.com/github.com/tj/node-prune.sh | bash -s -- -b /usr/local/bin 10 | 11 | WORKDIR /app 12 | COPY package.json yarn.lock ./ 13 | RUN yarn --frozen-lockfile 14 | COPY . . 15 | 16 | RUN yarn build 17 | 18 | RUN npm prune --production 19 | 20 | # run node prune 21 | RUN /usr/local/bin/node-prune 22 | 23 | FROM node:12-alpine 24 | 25 | WORKDIR /app 26 | 27 | COPY . . 28 | 29 | # copy from build image 30 | COPY --from=BUILD_IMAGE /app/dist ./dist 31 | COPY --from=BUILD_IMAGE /app/node_modules ./node_modules 32 | 33 | ENV NODE_ENV=production 34 | 35 | ENTRYPOINT [ "yarn", "serve" ] -------------------------------------------------------------------------------- /src/services/mongoose/index.ts: -------------------------------------------------------------------------------- 1 | import { mongoose } from '@democracy-deutschland/democracy-common'; 2 | 3 | /* 4 | THIS FILE AND ALL IMPORTS ARE NOT ALLOWED TO INCLUDE ANY MONGOOSE MODELS 5 | See index.js for more info 6 | */ 7 | import CONFIG from '../../config'; 8 | 9 | export default async () => { 10 | // Mongo Debug 11 | if (CONFIG.LOGGING_MONGO) { 12 | mongoose.set('debug', () => { 13 | // global.Log[CONFIG.LOGGING_MONGO](inspect(true)); 14 | }); 15 | } 16 | 17 | // Connect 18 | try { 19 | await mongoose.connect(CONFIG.DB_URL, { useNewUrlParser: true, reconnectTries: 86400 }); 20 | } catch (err) { 21 | global.Log.error(err); 22 | await mongoose.createConnection(CONFIG.DB_URL, {}); 23 | } 24 | 25 | // Open 26 | mongoose.connection 27 | .once('open', () => global.Log.info('MongoDB is running')) 28 | .on('error', (e) => { 29 | // Unknown if this ends up in main - therefore we log here 30 | global.Log.error(e.stack); 31 | throw e; 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /src/types/graphqlContext.ts: -------------------------------------------------------------------------------- 1 | import { Response, Request } from 'express'; 2 | import DataLoader from 'dataloader'; 3 | import { 4 | ProcedureModel, 5 | VoteModel, 6 | DeviceModel, 7 | Device, 8 | UserModel, 9 | User, 10 | DeputyModel, 11 | ActivityModel, 12 | SearchTermModel, 13 | VerificationModel, 14 | PhoneModel, 15 | Phone, 16 | } from '@democracy-deutschland/democracy-common'; 17 | 18 | export interface GraphQlContext { 19 | ProcedureModel: typeof ProcedureModel; 20 | VoteModel: typeof VoteModel; 21 | ActivityModel: typeof ActivityModel; 22 | DeviceModel: typeof DeviceModel; 23 | DeputyModel: typeof DeputyModel; 24 | SearchTermModel: typeof SearchTermModel; 25 | PhoneModel: typeof PhoneModel; 26 | VerificationModel: typeof VerificationModel; 27 | UserModel: typeof UserModel; 28 | res: Response; 29 | user: User; 30 | phone: Phone; 31 | device: Device; 32 | votedLoader: DataLoader; 33 | } 34 | 35 | export interface ExpressReqContext extends Request { 36 | user?: User | null; 37 | phone?: Phone | null; 38 | device?: Device | null; 39 | } 40 | -------------------------------------------------------------------------------- /src/graphql/resolvers/dataLoaders/VotedLoader.ts: -------------------------------------------------------------------------------- 1 | import { Device, Phone, VoteModel } from '@democracy-deutschland/democracy-common'; 2 | import { Types } from 'mongoose'; 3 | 4 | export const votedLoader = async ({ 5 | procedureObjIds, 6 | phone, 7 | device, 8 | }: { 9 | procedureObjIds: readonly Types.ObjectId[]; 10 | phone: Phone | null | undefined; 11 | device: Device | null | undefined; 12 | }) => { 13 | if (phone) { 14 | const votedProcedures = await VoteModel.find( 15 | { 16 | procedure: { $in: procedureObjIds }, 17 | type: 'Phone', 18 | voters: { 19 | $elemMatch: { 20 | voter: phone._id, 21 | }, 22 | }, 23 | }, 24 | { 25 | procedure: 1, 26 | }, 27 | ); 28 | const votedProcedureObjIds = votedProcedures.map(({ procedure }) => 29 | typeof procedure === 'string' ? procedure : (procedure as Types.ObjectId).toHexString(), 30 | ); 31 | 32 | return procedureObjIds.map((procedureObjId) => { 33 | if (typeof procedureObjId === 'string') { 34 | return votedProcedureObjIds.includes(procedureObjId); 35 | } else { 36 | return votedProcedureObjIds.includes(procedureObjId.toHexString()); 37 | } 38 | }); 39 | } 40 | return procedureObjIds.map(() => false); 41 | }; 42 | -------------------------------------------------------------------------------- /src/services/cronJobs/index.ts: -------------------------------------------------------------------------------- 1 | import { CronJob } from 'cron'; 2 | 3 | import CONFIG from '../../config'; 4 | 5 | import { 6 | resetCronSuccessStartDate, 7 | resetCronRunningState, 8 | } from '@democracy-deutschland/democracy-common'; 9 | 10 | // global variable to store cronjobs 11 | const jobs: CronJob[] = []; 12 | 13 | const registerCronJob = ({ 14 | name, 15 | cronTime, 16 | cronTask, 17 | startOnInit, 18 | }: { 19 | name: string; 20 | cronTime?: string; 21 | cronTask: () => void; 22 | startOnInit: boolean; 23 | }) => { 24 | if (cronTime) { 25 | jobs.push(new CronJob(cronTime, cronTask, null, true, 'Europe/Berlin', null, startOnInit)); 26 | global.Log.info(`[Cronjob][${name}] registered: ${cronTime}`); 27 | } else { 28 | global.Log.warn(`[Cronjob][${name}] disabled`); 29 | } 30 | }; 31 | 32 | const cronJobs = async () => { 33 | // Server freshly started -> Reset all cron states 34 | // This assumes that only one instance is running on the same database 35 | await resetCronRunningState(); 36 | // SheduleBIOResync - Shedule complete Resync with Bundestag.io 37 | registerCronJob({ 38 | name: 'SheduleBIOResync', 39 | cronTime: CONFIG.CRON_SHEDULE_BIO_RESYNC, // 55 3 * */1 * 40 | cronTask: resetCronSuccessStartDate, 41 | startOnInit: /* CONFIG.CRON_START_ON_INIT */ false, // dangerous 42 | }); 43 | // Return 44 | return jobs; 45 | }; 46 | 47 | module.exports = cronJobs; 48 | -------------------------------------------------------------------------------- /src/config/cronjobConfig.ts: -------------------------------------------------------------------------------- 1 | import { testCronTime } from '@democracy-deutschland/democracy-common'; 2 | 3 | export default { 4 | CRON_START_ON_INIT: process.env.CRON_START_ON_INIT === 'true', 5 | CRON_PROCEDURES: testCronTime(process.env.CRON_PROCEDURES) 6 | ? process.env.CRON_PROCEDURES 7 | : undefined, 8 | CRON_NAMED_POLLS: testCronTime(process.env.CRON_NAMED_POLLS) 9 | ? process.env.CRON_NAMED_POLLS 10 | : undefined, 11 | CRON_DEPUTY_PROFILES: testCronTime(process.env.CRON_DEPUTY_PROFILES) 12 | ? process.env.CRON_DEPUTY_PROFILES 13 | : undefined, 14 | CRON_SHEDULE_BIO_RESYNC: testCronTime(process.env.CRON_SHEDULE_BIO_RESYNC) 15 | ? process.env.CRON_SHEDULE_BIO_RESYNC 16 | : undefined, 17 | CRON_SEND_QUED_PUSHS: testCronTime(process.env.CRON_SEND_QUED_PUSHS) 18 | ? process.env.CRON_SEND_QUED_PUSHS 19 | : undefined, 20 | CRON_SEND_QUED_PUSHS_LIMIT: process.env.CRON_SEND_QUED_PUSHS_LIMIT 21 | ? parseInt(process.env.CRON_SEND_QUED_PUSHS_LIMIT) 22 | : 0, 23 | CRON_QUE_PUSHS_CONFERENCE_WEEK: testCronTime(process.env.CRON_QUE_PUSHS_CONFERENCE_WEEK) 24 | ? process.env.CRON_QUE_PUSHS_CONFERENCE_WEEK 25 | : undefined, 26 | CRON_QUE_PUSHS_VOTE_TOP100: testCronTime(process.env.CRON_QUE_PUSHS_VOTE_TOP100) 27 | ? process.env.CRON_QUE_PUSHS_VOTE_TOP100 28 | : undefined, 29 | CRON_QUE_PUSHS_VOTE_CONFERENCE_WEEK: testCronTime(process.env.CRON_QUE_PUSHS_VOTE_CONFERENCE_WEEK) 30 | ? process.env.CRON_QUE_PUSHS_VOTE_CONFERENCE_WEEK 31 | : undefined, 32 | }; 33 | -------------------------------------------------------------------------------- /src/graphql/resolvers/SearchTerm.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | 3 | import { Resolvers } from '../../generated/graphql'; 4 | 5 | const SearchTermApi: Resolvers = { 6 | Query: { 7 | mostSearched: async (parent, args, { SearchTermModel }) => { 8 | global.Log.graphql('SearchTerm.query.mostSearched'); 9 | const result = await SearchTermModel.aggregate([ 10 | { $unwind: '$times' }, 11 | { 12 | $group: { 13 | _id: '$term', 14 | times: { $push: '$times' }, 15 | size: { $sum: 1 }, 16 | }, 17 | }, 18 | { $sort: { size: -1 } }, 19 | { $limit: 10 }, 20 | ]); 21 | return result.map(({ _id }) => ({ term: _id })); 22 | }, 23 | }, 24 | Mutation: { 25 | finishSearch: async (parent, { term }, { SearchTermModel, user }) => { 26 | global.Log.graphql('SearchTerm.mutation.finishSearchs'); 27 | if (user && user.isVerified()) { 28 | return { term }; 29 | } 30 | if (term && term.trim().length >= 3) { 31 | SearchTermModel.findOneAndUpdate( 32 | { 33 | term: term.toLowerCase().trim(), 34 | }, 35 | { 36 | $push: { 37 | times: new Date(), 38 | }, 39 | }, 40 | { 41 | upsert: true, 42 | }, 43 | ).then(); 44 | } 45 | return { term }; 46 | }, 47 | }, 48 | }; 49 | 50 | export default SearchTermApi; 51 | -------------------------------------------------------------------------------- /src/express/auth/permissions.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | import { rule, shield } from 'graphql-shield'; 3 | import CONFIG from '../../config'; 4 | 5 | // User & Device is existent in Database 6 | const isLoggedin = rule({ cache: 'no_cache' })(async (parent, args, { user, device }) => { 7 | if (!user || !device) { 8 | global.Log.warn('Permission denied: You need to login with your Device'); 9 | return false; 10 | } 11 | return true; 12 | }); 13 | 14 | const isVerified = rule({ cache: 'no_cache' })(async (parent, args, { user, phone }) => { 15 | if (!user || (CONFIG.SMS_VERIFICATION && (!user.isVerified() || !phone))) { 16 | global.Log.warn('Permission denied: isVerified = false'); 17 | return false; 18 | } 19 | return true; 20 | }); 21 | 22 | export const permissions = shield( 23 | { 24 | Query: { 25 | // procedures: isLoggedin, 26 | // activityIndex: isLoggedin, 27 | notificationSettings: isLoggedin, 28 | notifiedProcedures: isLoggedin, 29 | votes: isLoggedin, 30 | votedProcedures: isVerified, 31 | }, 32 | Mutation: { 33 | increaseActivity: isVerified, 34 | vote: isVerified, 35 | requestCode: isLoggedin, 36 | requestVerification: isLoggedin, 37 | addToken: isLoggedin, 38 | updateNotificationSettings: isLoggedin, 39 | toggleNotification: isLoggedin, 40 | finishSearch: isLoggedin, 41 | // createResolver: isLoggedin, 42 | }, 43 | }, 44 | { 45 | debug: true, 46 | }, 47 | ); 48 | -------------------------------------------------------------------------------- /.github/workflows/build-docker.yaml: -------------------------------------------------------------------------------- 1 | name: democracy-app.de-server/ Build and Push Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | branches: 11 | - 'master' 12 | 13 | jobs: 14 | push: 15 | runs-on: ubuntu-latest 16 | environment: docker 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | - name: Docker meta 21 | id: meta 22 | uses: docker/metadata-action@v3 23 | with: 24 | images: democracy/democracy-server 25 | tags: | 26 | type=ref,event=branch 27 | type=ref,event=pr 28 | type=semver,pattern={{version}} 29 | type=semver,pattern={{major}}.{{minor}} 30 | type=semver,pattern={{major}} 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v1 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v1 35 | - name: Login to DockerHub 36 | uses: docker/login-action@v1 37 | if: github.event_name != 'pull_request' 38 | with: 39 | username: ${{ secrets.DOCKERHUB_USERNAME }} 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | - name: Build and push 42 | id: docker_build 43 | uses: docker/build-push-action@v2 44 | with: 45 | context: . 46 | push: ${{ github.event_name != 'pull_request' }} 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | - name: Image digest 50 | run: echo ${{ steps.docker_build.outputs.digest }} 51 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # General 2 | ## NODE_ENV: development | production 3 | NODE_ENV=production 4 | ## Stage: dev | alpha | beta | production | internal 5 | STAGE=dev 6 | 7 | # Debug 8 | DEBUG=true 9 | 10 | # Webhooks 11 | # BUNDESTAGIO_SERVER_URL= 12 | 13 | # JWT 14 | AUTH_JWT_SECRET=CHANGE_ME 15 | AUTH_JWT_TTL=1m 16 | AUTH_JWT_REFRESH_TTL=5m 17 | JWT_BACKWARD_COMPATIBILITY=true 18 | 19 | # SMS 20 | SMS_VERIFICATION=false 21 | SMS_PROVIDER_KEY= 22 | # SMS_VERIFICATION_NEW_USER_DELAY= 23 | # SMS_SIMULATE=true 24 | 25 | # GraphQL 26 | GRAPHIQL=true 27 | 28 | # Apollo Engine 29 | ENGINE_API_KEY= 30 | ENGINE_DEBUG_MODE= 31 | 32 | # Voyager 33 | VOYAGER=true 34 | 35 | # Push 36 | APPLE_TEAMID= 37 | APPLE_APN_KEY_ID= 38 | APPLE_APN_KEY= 39 | NOTIFICATION_ANDROID_SERVER_KEY= 40 | 41 | # Cron 42 | CRON_START_ON_INIT=false 43 | CRON_PROCEDURES=*/15 * * * * 44 | CRON_NAMED_POLLS=*/15 * * * * 45 | CRON_DEPUTY_PROFILES=*/15 * * * * 46 | CRON_SHEDULE_BIO_RESYNC=55 3 * */1 * 47 | CRON_SEND_QUED_PUSHS=*/15 7-22 * * * 48 | CRON_SEND_QUED_PUSHS_LIMIT=100 49 | CRON_QUE_PUSHS_CONFERENCE_WEEK=0 14 * * SUN 50 | CRON_QUE_PUSHS_VOTE_TOP100=0 4 * * MON-FRI 51 | CRON_QUE_PUSHS_VOTE_CONFERENCE_WEEK=0 4 * * MON-FRI 52 | 53 | # Logging 54 | ## ( LEVEL: [error|warn|info|verbose|debug|silly] ) 55 | LOGGING_CONSOLE=silly 56 | LOGGING_FILE=silly 57 | LOGGING_MONGO=silly 58 | LOGGING_DISCORD=silly 59 | LOGGING_DISCORD_TOKEN= 60 | LOGGING_DISCORD_WEBHOOK=https://discordapp.com/api/webhooks/... 61 | 62 | # RemoveMe 63 | SECRET_KEY=CHANGE_ME 64 | 65 | # Human Connection 66 | HC_BACKEND_URL= 67 | HC_ORGANIZATION_SLUG= 68 | HC_LOGIN_EMAIL= 69 | HC_LOGIN_PASSWORD= 70 | 71 | # Enable express status 72 | EXPRESS_STATUS=true -------------------------------------------------------------------------------- /src/graphql/schemas/Vote.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | enum VoteSelection { 4 | YES 5 | NO 6 | ABSTINATION 7 | NOTVOTED 8 | } 9 | 10 | type CommunityConstituencyVotes { 11 | constituency: String! 12 | yes: Int! 13 | no: Int! 14 | abstination: Int! 15 | total: Int! 16 | } 17 | 18 | type CommunityVotes { 19 | yes: Int! 20 | no: Int! 21 | abstination: Int! 22 | total: Int! 23 | constituencies: [CommunityConstituencyVotes!]! 24 | } 25 | 26 | type DeputyVote { 27 | deputy: Deputy! 28 | decision: VoteSelection! 29 | } 30 | 31 | type VoteResult { 32 | procedureId: String! 33 | yes: Int! 34 | no: Int! 35 | abstination: Int! 36 | notVoted: Int 37 | notVote: Int @deprecated 38 | governmentDecision: VoteSelection! 39 | decisionText: String 40 | namedVote: Boolean! 41 | partyVotes: [PartyVote!]! 42 | deputyVotes(constituencies: [String!], directCandidate: Boolean): [DeputyVote!]! 43 | } 44 | 45 | type PartyVote { 46 | party: String! 47 | main: VoteSelection! 48 | deviants: Deviants! 49 | } 50 | 51 | type Deviants { 52 | yes: Int! 53 | abstination: Int! 54 | no: Int! 55 | notVoted: Int 56 | } 57 | 58 | type Vote { 59 | _id: ID! 60 | voted: Boolean! 61 | voteResults: CommunityVotes 62 | } 63 | 64 | type VoteStatistic { 65 | proceduresCount: Int! 66 | votedProcedures: Int! 67 | } 68 | 69 | type Mutation { 70 | vote(procedure: ID!, selection: VoteSelection!, constituency: String): Vote! 71 | } 72 | 73 | 74 | type Query { 75 | votes(procedure: ID!, constituencies: [String!]): Vote 76 | communityVotes(procedure: ID!, constituencies: [String!]): CommunityVotes 77 | voteStatistic: VoteStatistic 78 | } 79 | `; 80 | -------------------------------------------------------------------------------- /src/graphql/schemas/Device.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | 3 | type Device { 4 | notificationSettings: NotificationSettings 5 | } 6 | 7 | type TokenResult { 8 | succeeded: Boolean 9 | } 10 | 11 | type CodeResult { 12 | reason: String 13 | allowNewUser: Boolean 14 | succeeded: Boolean! 15 | resendTime: Date 16 | expireTime: Date 17 | } 18 | 19 | type VerificationResult { 20 | reason: String 21 | succeeded: Boolean! 22 | } 23 | 24 | type NotificationSettings { 25 | enabled: Boolean 26 | newVote: Boolean @deprecated(reason: "<= 1.22 Notification Settings") 27 | newPreperation: Boolean @deprecated(reason: "<= 1.22 Notification Settings") 28 | conferenceWeekPushs: Boolean 29 | voteConferenceWeekPushs: Boolean 30 | voteTOP100Pushs: Boolean 31 | outcomePushs: Boolean 32 | disableUntil: Date 33 | procedures: [String] 34 | tags: [String] 35 | } 36 | 37 | type Query { 38 | notificationSettings: NotificationSettings 39 | } 40 | 41 | type Mutation { 42 | requestCode(newPhone: String!, oldPhoneHash: String): CodeResult! 43 | requestVerification(code: String!, newPhoneHash: String!, newUser: Boolean): VerificationResult! 44 | 45 | addToken(token: String!, os: String!): TokenResult! 46 | 47 | ${/* DEPRECATED newVote & newPreperation: <= 1.22 Notification Settings */ ''} 48 | updateNotificationSettings( 49 | enabled: Boolean, 50 | newVote: Boolean, 51 | newPreperation: Boolean, 52 | conferenceWeekPushs: Boolean, 53 | voteConferenceWeekPushs: Boolean, 54 | voteTOP100Pushs: Boolean, 55 | outcomePushs: Boolean, 56 | outcomePushsEnableOld: Boolean, 57 | disableUntil: Date, 58 | procedures: [String], 59 | tags: [String] 60 | ): NotificationSettings 61 | 62 | toggleNotification(procedureId: String!): Procedure 63 | } 64 | 65 | `; 66 | -------------------------------------------------------------------------------- /src/services/graphql/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer, makeExecutableSchema } from 'apollo-server-express'; 2 | import { applyMiddleware } from 'graphql-middleware'; 3 | import CONFIG from '../../config'; 4 | import { ExpressReqContext } from '../../types/graphqlContext'; 5 | 6 | import typeDefs from '../../graphql/schemas'; 7 | import resolvers from '../../graphql/resolvers'; 8 | import { permissions } from '../../express/auth/permissions'; 9 | import DataLoader from 'dataloader'; 10 | import { votedLoader } from '../../graphql/resolvers/dataLoaders'; 11 | 12 | // Models 13 | import { 14 | ProcedureModel, 15 | UserModel, 16 | DeviceModel, 17 | PushNotificationModel, 18 | VoteModel, 19 | PhoneModel, 20 | VerificationModel, 21 | ActivityModel, 22 | SearchTermModel, 23 | DeputyModel, 24 | } from '@democracy-deutschland/democracy-common'; 25 | import { Types } from 'mongoose'; 26 | 27 | const schema = makeExecutableSchema({ typeDefs, resolvers }); 28 | 29 | const graphql = new ApolloServer({ 30 | uploads: false, 31 | engine: CONFIG.ENGINE_API_KEY 32 | ? { 33 | apiKey: CONFIG.ENGINE_API_KEY, 34 | // Send params and headers to engine 35 | privateVariables: !CONFIG.ENGINE_DEBUG_MODE, 36 | privateHeaders: !CONFIG.ENGINE_DEBUG_MODE, 37 | } 38 | : false, 39 | typeDefs, 40 | schema: applyMiddleware(schema, permissions), 41 | resolvers, 42 | introspection: true, 43 | playground: CONFIG.GRAPHIQL, 44 | context: ({ req, res }: { req: ExpressReqContext; res: Express.Response }) => ({ 45 | // Connection 46 | res, 47 | // user 48 | user: req.user, 49 | device: req.device, 50 | phone: req.phone, 51 | // Models 52 | ProcedureModel, 53 | UserModel, 54 | DeviceModel, 55 | PhoneModel, 56 | VerificationModel, 57 | ActivityModel, 58 | VoteModel, 59 | PushNotificationModel, 60 | SearchTermModel, 61 | DeputyModel, 62 | votedLoader: new DataLoader((procedureObjIds) => 63 | votedLoader({ procedureObjIds, device: req.device, phone: req.phone }), 64 | ), 65 | }), 66 | tracing: CONFIG.DEBUG, 67 | }); 68 | 69 | module.exports = graphql; 70 | -------------------------------------------------------------------------------- /src/services/logger/index.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import { MongoDB } from 'winston-mongodb'; 3 | 4 | import CONFIG from '../../config'; 5 | 6 | const alignedWithColorsAndTime = winston.format.combine( 7 | winston.format.colorize(), 8 | winston.format.timestamp(), 9 | winston.format.align(), 10 | winston.format.printf(info => { 11 | const { timestamp, level, message, ...args } = info; 12 | const ts = timestamp.slice(0, 19).replace('T', ' '); 13 | return `${ts} [${level}]: ${message} ${ 14 | Object.keys(args).length ? JSON.stringify(args, null, 2) : '' 15 | }`; 16 | }), 17 | ); 18 | // const alignedWithTime = winston.format.combine( 19 | // winston.format.timestamp(), 20 | // winston.format.align(), 21 | // winston.format.printf(info => { 22 | // const { timestamp, level, message, ...args } = info; 23 | 24 | // const ts = timestamp.slice(0, 19).replace('T', ' '); 25 | // return `${ts} [${level}]: ${message} ${ 26 | // Object.keys(args).length ? JSON.stringify(args, null, 2) : '' 27 | // }`; 28 | // }), 29 | // ); 30 | 31 | const transports = [ 32 | new winston.transports.Console({ 33 | level: CONFIG.LOGGING_CONSOLE, 34 | format: alignedWithColorsAndTime, 35 | }), 36 | // new winston.transports.File({ 37 | // filename: 'logs/combined.log', 38 | // level: CONFIG.LOGGING_FILE, 39 | // format: alignedWithTime, 40 | // }), 41 | ]; 42 | 43 | const myLevels = { 44 | levels: { 45 | error: 0, 46 | warn: 1, 47 | info: 2, 48 | import: 4, 49 | jwt: 5, 50 | graphql: 6, 51 | verbose: 7, 52 | debug: 8, 53 | silly: 9, 54 | }, 55 | colors: { 56 | error: 'red', 57 | warn: 'yellow', 58 | info: 'green', 59 | notification: 'magenta', 60 | import: 'magenta', 61 | jwt: 'magenta', 62 | graphql: 'magenta', 63 | verbose: 'blue', 64 | debug: 'blue', 65 | silly: 'gray', 66 | }, 67 | }; 68 | 69 | const logger = winston.createLogger({ 70 | levels: myLevels.levels, 71 | transports, 72 | }); 73 | winston.addColors(myLevels.colors); 74 | logger.add( 75 | new MongoDB({ 76 | db: CONFIG.DB_URL, 77 | level: 'warn', 78 | }), 79 | ); 80 | global.Log = logger; 81 | -------------------------------------------------------------------------------- /src/graphql/resolvers/Activity.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | import { Types } from 'mongoose'; 3 | import CONFIG from '../../config'; 4 | import { Resolvers } from '../../generated/graphql'; 5 | 6 | const ActivityApi: Resolvers = { 7 | Query: { 8 | activityIndex: async (parent, { procedureId }, { ProcedureModel, ActivityModel, user }) => { 9 | global.Log.graphql('Activity.query.activityIndex'); 10 | const procedure = await ProcedureModel.findOne({ procedureId }); 11 | if (procedure) { 12 | const activityIndex = await ActivityModel.find({ procedure }).count(); 13 | const active = await ActivityModel.findOne({ 14 | user, 15 | procedure, 16 | }); 17 | return { 18 | activityIndex, 19 | active: !!active, 20 | }; 21 | } 22 | return null; 23 | }, 24 | }, 25 | 26 | Mutation: { 27 | increaseActivity: async ( 28 | parent, 29 | { procedureId }, 30 | { ProcedureModel, ActivityModel, device, phone }, 31 | ) => { 32 | global.Log.graphql('Activity.mutation.increaseActivity'); 33 | let searchQuery; 34 | if (Types.ObjectId.isValid(procedureId)) { 35 | searchQuery = { _id: Types.ObjectId(procedureId) }; 36 | } else { 37 | searchQuery = { procedureId }; 38 | } 39 | const procedure = await ProcedureModel.findOne(searchQuery); 40 | if (!procedure) { 41 | throw new Error('Procedure not found'); 42 | } 43 | let active = await ActivityModel.findOne({ 44 | actor: CONFIG.SMS_VERIFICATION ? phone._id : device._id, 45 | kind: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 46 | procedure, 47 | }); 48 | if (!active) { 49 | active = await ActivityModel.create({ 50 | actor: CONFIG.SMS_VERIFICATION ? phone._id : device._id, 51 | kind: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 52 | procedure, 53 | }); 54 | } 55 | const activityIndex = await ActivityModel.find({ procedure }).count(); 56 | return { activityIndex, active: !!active }; 57 | }, 58 | }, 59 | }; 60 | 61 | export default ActivityApi; 62 | -------------------------------------------------------------------------------- /src/config/procedureStates.ts: -------------------------------------------------------------------------------- 1 | import { PROCEDURE as PROCEDURE_DEFINITIONS } from '@democracy-deutschland/bundestag.io-definitions'; 2 | 3 | export default { 4 | PREPARATION: [ 5 | PROCEDURE_DEFINITIONS.STATUS.BR_ZUGELEITET_NICHT_BERATEN, 6 | PROCEDURE_DEFINITIONS.STATUS.ZUGELEITET, 7 | PROCEDURE_DEFINITIONS.STATUS.AUSSCHUESSEN_ZUGEWIESEN, 8 | PROCEDURE_DEFINITIONS.STATUS.BR_ERSTER_DURCHGANG_ABGESCHLOSSEN, 9 | PROCEDURE_DEFINITIONS.STATUS.NICHT_BERATEN, 10 | PROCEDURE_DEFINITIONS.STATUS.EINBRINGUNG_ABGELEHNT, 11 | PROCEDURE_DEFINITIONS.STATUS.EINBRINGUNG_BESCHLOSSEN, 12 | PROCEDURE_DEFINITIONS.STATUS.BERATUNG_VORGANGSABLAUF, 13 | PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN, 14 | ], 15 | IN_VOTE: [ 16 | PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG, 17 | PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN, 18 | ], 19 | COMPLETED: [ 20 | PROCEDURE_DEFINITIONS.STATUS.NICHT_ABBESCHLOSSEN_VORGANGSABLAUF, 21 | PROCEDURE_DEFINITIONS.STATUS.ERLEDIGT_DURCH_WAHLPERIODE, 22 | PROCEDURE_DEFINITIONS.STATUS.ZURUECKGEZOGEN, 23 | PROCEDURE_DEFINITIONS.STATUS.ANGENOMMEN, 24 | PROCEDURE_DEFINITIONS.STATUS.ABGELEHNT, 25 | PROCEDURE_DEFINITIONS.STATUS.ABBESCHLOSSEN_VORGANGSABLAUF, 26 | PROCEDURE_DEFINITIONS.STATUS.ABGESCHLOSSEN, 27 | PROCEDURE_DEFINITIONS.STATUS.NICHTIG, 28 | PROCEDURE_DEFINITIONS.STATUS.VERKUENDET, 29 | PROCEDURE_DEFINITIONS.STATUS.ZUSAMMENGEFUEHRT_VORGANGSABLAUF, 30 | PROCEDURE_DEFINITIONS.STATUS.ERLEDIGT, 31 | PROCEDURE_DEFINITIONS.STATUS.VERABSCHIEDET, 32 | PROCEDURE_DEFINITIONS.STATUS.BR_ZUGESTIMMT, 33 | PROCEDURE_DEFINITIONS.STATUS.BR_EINSPRUCH, 34 | PROCEDURE_DEFINITIONS.STATUS.BR_ZUSTIMMUNG_VERSAGT, 35 | PROCEDURE_DEFINITIONS.STATUS.BR_VERMITTLUNGSAUSSCHUSS_NICHT_ANGERUFEN, 36 | PROCEDURE_DEFINITIONS.STATUS.VERMITTLUNGSVERFAHREN, 37 | PROCEDURE_DEFINITIONS.STATUS.VERMITTLUNGSVORSCHLAG, 38 | PROCEDURE_DEFINITIONS.STATUS.UNVEREINBAR_MIT_GRUNDGESETZ, 39 | PROCEDURE_DEFINITIONS.STATUS.BP_ZUSTIMMUNGSVERWEIGERUNG, 40 | PROCEDURE_DEFINITIONS.STATUS.ZUSTIMMUNG_VERSAGT, 41 | PROCEDURE_DEFINITIONS.STATUS.TEILE_FUER_NICHTIG_ERKLÄRT, 42 | PROCEDURE_DEFINITIONS.STATUS.GEGENSTANDSLOS, 43 | PROCEDURE_DEFINITIONS.STATUS.KEINE_BEHANDLUNG, 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /src/graphql/resolvers/User.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | import RSAKey from 'react-native-rsa'; 3 | import crypto from 'crypto'; 4 | 5 | import { createTokens, headerToken } from '../../express/auth'; 6 | import CONFIG from '../../config'; 7 | import { Resolvers } from '../../generated/graphql'; 8 | 9 | const UserApi: Resolvers = { 10 | Query: { 11 | me: async (parent, args, { UserModel, user, device }) => { 12 | global.Log.graphql('User.query.me'); 13 | if (!user) { 14 | return null; 15 | } 16 | // Normal Code - remove stuff above and enable isLoggedin resolver 17 | // Maybe return user; ? 18 | const dbUser = await UserModel.findById(user._id); 19 | const { deviceHash } = device; 20 | if (dbUser) { 21 | return { ...dbUser.toObject(), deviceHash }; 22 | } 23 | } /* ) */, 24 | }, 25 | Mutation: { 26 | signUp: async (parent, { deviceHashEncrypted }, { res, UserModel, DeviceModel }) => { 27 | global.Log.graphql('User.mutation.signUp'); 28 | if (!CONFIG.JWT_BACKWARD_COMPATIBILITY) { 29 | return null; 30 | } 31 | const rsa = new RSAKey(); 32 | 33 | rsa.setPrivateString(CONFIG.SECRET_KEY); 34 | const deviceHash = rsa.decrypt(deviceHashEncrypted); 35 | if (!deviceHash) { 36 | throw new Error('invalid deviceHash'); 37 | } 38 | 39 | let device = await DeviceModel.findOne({ 40 | deviceHash: crypto 41 | .createHash('sha256') 42 | .update(deviceHash) 43 | .digest('hex'), 44 | }); 45 | if (!device) { 46 | device = await DeviceModel.create({ 47 | deviceHash: crypto 48 | .createHash('sha256') 49 | .update(deviceHash) 50 | .digest('hex'), 51 | }).catch(e => { 52 | console.log(e); 53 | throw new Error('Error on save device'); 54 | }); 55 | } 56 | 57 | let user = await UserModel.findOne({ device }); 58 | if (!user) { 59 | user = await UserModel.create({ device }); 60 | } 61 | 62 | const [token, refreshToken] = await createTokens(user._id); 63 | headerToken({ res, token, refreshToken }); 64 | return { token }; 65 | }, 66 | }, 67 | }; 68 | 69 | export default UserApi; 70 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | THIS FILE AND ALL IMPORTS ARE NOT ALLOWED TO INCLUDE ANY MONGOOSE MODELS 3 | See index.js for more info 4 | */ 5 | 6 | import jwt from './jwt'; 7 | import smsverification from './smsverification'; 8 | // import humanconnection from './humanconnection'; 9 | import cronjobConfig from './cronjobConfig'; 10 | 11 | const requiredConfigs = { 12 | // No default Values 13 | ...smsverification, 14 | ...jwt, 15 | }; 16 | 17 | const recommendedConfigs = { 18 | // No correct default Values 19 | PORT: process.env.PORT || 3000, 20 | MIN_PERIOD: parseInt(process.env.MIN_PERIOD || '19', 10), 21 | GRAPHQL_PATH: process.env.GRAPHQL_PATH || '/', 22 | GRAPHIQL: process.env.GRAPHIQL === 'true', 23 | DB_URL: process.env.DB_URL || 'mongodb://localhost/democracy_development', 24 | ELASTICSEARCH_URL: process.env.ELASTICSEARCH_URL || 'elasticsearch', 25 | BUNDESTAGIO_SERVER_URL: process.env.BUNDESTAGIO_SERVER_URL || 'http://localhost:3100/', 26 | APN_TOPIC: ((): string => { 27 | switch (process.env.STAGE) { 28 | case 'dev': 29 | return 'de.democracy-deutschland.clientapp.new'; 30 | case 'internal': 31 | return 'de.democracy-deutschland.clientapp.internal'; 32 | case 'alpha': 33 | return 'de.democracy-deutschland.clientapp.alpha'; 34 | case 'beta': 35 | return 'de.democracy-deutschland.clientapp.beta'; 36 | case 'production': 37 | return 'de.democracy-deutschland.clientapp'; 38 | default: 39 | console.error('ERROR: no STAGE defined!'); // eslint-disable-line no-console 40 | return 'de.democracy-deutschland.clientapp'; 41 | } 42 | })(), 43 | NOTIFICATION_ANDROID_SERVER_KEY: process.env.NOTIFICATION_ANDROID_SERVER_KEY || null, 44 | APPLE_APN_KEY: process.env.APPLE_APN_KEY || null, 45 | APPLE_APN_KEY_ID: process.env.APPLE_APN_KEY_ID || null, 46 | APPLE_TEAMID: process.env.APPLE_TEAMID || null, 47 | // ...humanconnection, 48 | ...cronjobConfig, 49 | }; 50 | 51 | const optionalConfigs = { 52 | // Default Values given 53 | DEBUG: process.env.DEBUG === 'true', 54 | ENGINE_API_KEY: process.env.ENGINE_API_KEY || null, 55 | ENGINE_DEBUG_MODE: process.env.ENGINE_DEBUG_MODE === 'true', 56 | VOYAGER: process.env.VOYAGER || false, 57 | // Logging 58 | LOGGING_CONSOLE: process.env.LOGGING_CONSOLE, 59 | LOGGING_FILE: process.env.LOGGING_FILE, 60 | LOGGING_DISCORD: process.env.LOGGING_DISCORD, 61 | LOGGING_DISCORD_TOKEN: process.env.LOGGING_DISCORD_TOKEN, 62 | LOGGING_DISCORD_WEBHOOK: process.env.LOGGING_DISCORD_WEBHOOK, 63 | LOGGING_MONGO: process.env.LOGGING_MONGO || false, 64 | }; 65 | 66 | export default { 67 | ...requiredConfigs, 68 | ...recommendedConfigs, 69 | ...optionalConfigs, 70 | }; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "democracy-server", 3 | "version": "0.2.32", 4 | "main": "dist/index.js", 5 | "author": "Manuel Ruck", 6 | "license": "Apache2", 7 | "private": true, 8 | "scripts": { 9 | "build": "tsc", 10 | "serve": "node dist/index.js", 11 | "dev": "ts-node-dev src/index.ts", 12 | "dev:local": "ts-node-dev -r dotenv/config src/index.ts", 13 | "generate": "graphql-codegen --config codegen.self.yml", 14 | "apollo:codegen": "apollo client:codegen --endpoint=http://localhost:3100 --excludes=node_modules/* --target typescript --globalTypesFile=./__generated__/globalTypes.ts", 15 | "lint": "yarn lint:es && yarn lint:ts && yarn lint:exports", 16 | "lint:es": "eslint src --ext .js,.jsx,.ts,.tsx --fix", 17 | "lint:ts": "tsc --noEmit", 18 | "lint:exports": "ts-unused-exports ./tsconfig.json --excludePathsFromReport=generated --excludePathsFromReport=resolvers --excludePathsFromReport=/schemas" 19 | }, 20 | "dependencies": { 21 | "@democracy-deutschland/bundestag.io-definitions": "^1.0.0", 22 | "@democracy-deutschland/democracy-common": "^0.2.7", 23 | "apn": "^2.2.0", 24 | "apollo-server-express": "^2.9.12", 25 | "body-parser": "^1.19.0", 26 | "cookie-parser": "^1.4.4", 27 | "cors": "^2.8.4", 28 | "cron": "^1.7.2", 29 | "dataloader": "^2.0.0", 30 | "elasticsearch": "^16.5.0", 31 | "express": "^4.17.1", 32 | "express-status-monitor": "^1.2.7", 33 | "graphql": "^14.5.8", 34 | "graphql-date": "^1.0.3", 35 | "graphql-middleware": "^4.0.2", 36 | "graphql-parse-resolve-info": "^4.5.0", 37 | "graphql-shield": "^7.2.1", 38 | "http2": "^3.3.7", 39 | "jsonwebtoken": "^8.5.1", 40 | "lodash": "^4.17.15", 41 | "merge-graphql-schemas": "^1.7.6", 42 | "moment": "^2.22.2", 43 | "mongoose": "^5.9.5", 44 | "ms": "^2.1.2", 45 | "node-gcm": "^1.0.2", 46 | "p-iteration": "^1.1.8", 47 | "react-native-rsa": "^0.0.3", 48 | "request": "^2.87.0", 49 | "winston": "^3.2.1", 50 | "winston-mongodb": "^5.0.1" 51 | }, 52 | "devDependencies": { 53 | "@types/cookie-parser": "^1.4.2", 54 | "@types/cron": "^1.7.2", 55 | "@types/elasticsearch": "^5.0.36", 56 | "@types/graphql-date": "^1.0.5", 57 | "@types/jsonwebtoken": "^8.3.8", 58 | "@types/lodash": "^4.14.149", 59 | "@types/mongoose": "^5.7.6", 60 | "@types/ms": "^0.7.31", 61 | "@types/request": "^2.48.4", 62 | "@types/winston": "^2.4.4", 63 | "@typescript-eslint/eslint-plugin": "^2.25.0", 64 | "@typescript-eslint/parser": "^2.25.0", 65 | "dotenv": "^8.2.0", 66 | "eslint": "^6.8.0", 67 | "eslint-config-prettier": "^6.10.1", 68 | "ts-node-dev": "^1.0.0-pre.44", 69 | "ts-unused-exports": "^6.2.1", 70 | "typescript": "^3.9.5", 71 | "utility-types": "^3.10.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | /* eslint-disable no-console */ 3 | 4 | import express from 'express'; 5 | import bodyParser from 'body-parser'; 6 | import cors from 'cors'; 7 | import cookieParser from 'cookie-parser'; 8 | import { authMiddleware } from './express/auth'; 9 | 10 | // ***************************************************************** 11 | // IMPORTANT - you cannot include any models before migrating the DB 12 | // ***************************************************************** 13 | 14 | import CONFIG from './config'; 15 | 16 | // Allow global global.Log 17 | import './services/logger'; 18 | 19 | import connectDB from './services/mongoose'; 20 | 21 | const main = async () => { 22 | // Connect to DB - this keeps the process running 23 | // IMPORTANT - This is done before any Model is registered 24 | await connectDB(); 25 | 26 | // Express Server 27 | const server = express(); 28 | 29 | if (process.env.EXPRESS_STATUS === 'true') { 30 | server.use(require('express-status-monitor')()); // eslint-disable-line global-require 31 | } 32 | 33 | // Cors 34 | server.use(cors(/* corsOptions */)); 35 | /* 36 | const corsOptions = { 37 | origin: '*', 38 | // credentials: true, // <-- REQUIRED backend setting 39 | }; 40 | */ 41 | 42 | // Bodyparser 43 | server.use(bodyParser.json()); 44 | 45 | // Cookie parser to debug JWT easily 46 | if (CONFIG.DEBUG) { 47 | server.use(cookieParser()); 48 | } 49 | 50 | // Authentification 51 | // Here several Models are included 52 | 53 | server.use(authMiddleware); 54 | 55 | // Graphiql Playground 56 | // Here several Models are included for graphql 57 | // This must be registered before graphql since it binds on / (default) 58 | // if (CONFIG.GRAPHIQL_PATH) { 59 | // const graphiql = require('./services/graphiql'); // eslint-disable-line global-require 60 | // graphiql.applyMiddleware({ app: server, path: CONFIG.GRAPHIQL_PATH }); 61 | // } 62 | 63 | // Human Connection webhook 64 | // const smHumanConnection = require('./express/webhooks/socialmedia/humanconnection'); // eslint-disable-line global-require 65 | // server.get('/webhooks/human-connection/contribute', smHumanConnection); 66 | 67 | // Graphql 68 | // Here several Models are included for graphql 69 | const graphql = require('./services/graphql'); // eslint-disable-line global-require 70 | graphql.applyMiddleware({ app: server, path: CONFIG.GRAPHQL_PATH }); 71 | 72 | // Start Server 73 | server.listen({ port: CONFIG.PORT }, () => { 74 | global.Log.info(`🚀 Server ready at http://localhost:${CONFIG.PORT}${CONFIG.GRAPHQL_PATH}`, { 75 | metaKey: 'metaValue', 76 | }); 77 | }); 78 | 79 | // Start CronJobs (Bundestag Importer) 80 | // Serveral Models are included 81 | const cronJobs = require('./services/cronJobs'); // eslint-disable-line global-require 82 | cronJobs(); 83 | }; 84 | 85 | // Async Wrapping Function 86 | // Catches all errors 87 | (async () => { 88 | try { 89 | await main(); 90 | } catch (error) { 91 | global.Log.error(error.stack); 92 | } 93 | })(); 94 | -------------------------------------------------------------------------------- /src/graphql/schemas/Procedure.ts: -------------------------------------------------------------------------------- 1 | export default ` 2 | ${/* DEPRECATED ListType 2019-01-29 Renamed filed VOTING to PAST and IN_VOTE */ ''} 3 | enum ProcedureType { 4 | IN_VOTE @deprecated(reason: "Use procedures Query param listTypes instead of type") 5 | PREPARATION @deprecated(reason: "Use procedures Query param listTypes instead of type") 6 | VOTING @deprecated(reason: "Use procedures Query param listTypes instead of type") 7 | PAST @deprecated(reason: "Use procedures Query param listTypes instead of type") 8 | HOT @deprecated(reason: "Use procedures Query param listTypes instead of type") 9 | } 10 | 11 | enum ListType { 12 | PREPARATION 13 | IN_VOTE 14 | PAST 15 | HOT 16 | TOP100 17 | CONFERENCEWEEKS_PLANNED 18 | } 19 | 20 | type Procedure { 21 | _id: ID! 22 | title: String! 23 | procedureId: String! 24 | type: String! 25 | period: Int 26 | currentStatus: String 27 | currentStatusHistory: [String!]! 28 | abstract: String 29 | tags: [String!]! 30 | voteDate: Date 31 | voteEnd: Date 32 | voteWeek: Int 33 | voteYear: Int 34 | sessionTOPHeading: String 35 | subjectGroups: [String!]! 36 | submissionDate: Date 37 | activityIndex: ActivityIndex! 38 | votes: Int! 39 | importantDocuments: [Document!]! 40 | voteResults: VoteResult 41 | communityVotes(constituencies: [String!]): CommunityVotes 42 | voted: Boolean! 43 | votedGovernment: Boolean 44 | completed: Boolean 45 | notify: Boolean 46 | ${/* DEPRECATED ListType 2019-01-29 Renamed filed VOTING to PAST and IN_VOTE */ ''} 47 | listType: ProcedureType @deprecated(reason: "Use listTypes instead of type") 48 | list: ListType 49 | verified: Boolean 50 | } 51 | 52 | type SearchProcedures { 53 | procedures: [Procedure!]! 54 | autocomplete: [String!]! 55 | } 56 | 57 | input ProcedureFilter { 58 | subjectGroups: [String!] 59 | status: [String!] 60 | type: [String!] 61 | activity: [String!] 62 | } 63 | 64 | input ProcedureWOMFilter { 65 | subjectGroups: [String!]! 66 | } 67 | 68 | enum VotedTimeSpan { 69 | CurrentSittingWeek 70 | LastSittingWeek 71 | CurrentQuarter 72 | LastQuarter 73 | CurrentYear 74 | LastYear 75 | Period 76 | } 77 | 78 | type ProceduresHavingVoteResults { 79 | total: Int! 80 | procedures: [Procedure!]! 81 | } 82 | 83 | type Query { 84 | procedure(id: ID!): Procedure! 85 | ${/* DEPRECATED listType 2019-01-29 Renamed filed VOTING to PAST and IN_VOTE */ ''} 86 | procedures(listTypes: [ListType!], type: ProcedureType, pageSize: Int, offset: Int, sort: String, filter: ProcedureFilter): [Procedure!]! 87 | proceduresById(ids: [String!]!, pageSize: Int, offset: Int): [Procedure!]! 88 | proceduresByIdHavingVoteResults(procedureIds: [String!], timespan: VotedTimeSpan, pageSize: Int, offset: Int, filter: ProcedureWOMFilter): ProceduresHavingVoteResults! 89 | notifiedProcedures: [Procedure!]! 90 | searchProcedures(term: String!): [Procedure!]! @deprecated(reason: "use searchProceduresAutocomplete") 91 | searchProceduresAutocomplete(term: String!): SearchProcedures! 92 | votedProcedures: [Procedure!]! 93 | proceduresWithVoteResults(procedureIds: [String!]!): [Procedure!]! 94 | } 95 | `; 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Screenshot](https://github.com/demokratie-live/democracy-assets/blob/master/images/forfb2.png) 2 | 3 | # DEMOCRACY Server for the DEMOCRACY App   4 | 5 | [![Build Status](https://travis-ci.org/demokratie-live/democracy-server.svg?branch=master)](https://travis-ci.org/demokratie-live/democracy-server) 6 | 7 | The Serversoftware for the DEMOCRACY APP. This is an API Defintion and Server for Data required and created by the DEMOCRACY App. 8 | 9 | ## Tech Stack 10 | 11 | - [Node.js][node], [Yarn][yarn], [JavaScript][js] 12 | 13 | [More Dependecies](https://github.com/demokratie-live/democracy-server/network/dependencies) 14 | 15 | ![Projekt Struktur](https://github.com/demokratie-live/democracy-assets/blob/master/docu/api_structure_server.png) 16 | 17 | ## Prerequisites 18 | 19 | - [Node.js][node] 20 | - [MongoDB][mongo] 21 | 22 | ## Getting started 23 | 24 | Clone the git repo & run the project 25 | 26 | ``` 27 | git clone git@github.com:demokratie-live/democracy-server.git 28 | cd democracy-server 29 | yarn install 30 | ``` 31 | 32 | Rename the `.env.example` file to `.env` (Windows: `.env.`) 33 | 34 | ### Compile and start 35 | 36 | ``` 37 | yarn dev 38 | ``` 39 | 40 | ### Import Data from local Bundestag.io Server 41 | 42 | A local bundestag.io server will automagically scrape the latest procedures and 43 | update the democracy-server database. 44 | Run a local bundestag.io server according to its 45 | [README](https://github.com/demokratie-live/bundestag.io) and wait for the cron 46 | job to finish. 47 | 48 | ### Test Project 49 | 50 | ``` 51 | yarn lint 52 | ``` 53 | 54 | ## Diagrams 55 | 56 | All sorts of visual Documentation. 57 | 58 | ### Server <-> Bundestag.io 59 | 60 | ![](https://github.com/demokratie-live/democracy-assets/blob/master/docu/Communication_bundestagio_democracyserver.png) 61 | 62 | ### Database Model Prototyp 63 | 64 | ![](https://github.com/demokratie-live/democracy-assets/blob/master/docu/Datenbank%20Model.png) 65 | ![](https://github.com/demokratie-live/democracy-assets/blob/master/docu/ServerDatabase.png) 66 | 67 | ### Client -> Server: Increase Activity 68 | 69 | ![](https://github.com/demokratie-live/democracy-assets/blob/master/docu/activity.png) 70 | 71 | ### Client -> Server: Authentification 72 | 73 | ![](https://github.com/demokratie-live/democracy-assets/blob/master/docu/auth.png) 74 | 75 | ## Contributing 76 | 77 | Anyone and everyone is welcome to [contribute](CONTRIBUTING.md). Start by checking out the list of 78 | [open issues](https://github.com/demokratie-live/democracy-server/issues). 79 | 80 | ## License 81 | 82 | Copyright © 2017-present DEMOCRACY Deutschland e.V.. This source code is licensed under the Apache 2.0 license found in the 83 | [LICENSE](https://github.com/demokratie-live/democracy-server/blob/master/LICENSE) file. 84 | 85 | --- 86 | 87 | Made with ♥ by Team DEMOCRACY ([democracy-deutschland.de](https://www.democracy-deutschland.de)), [startnext contributors](https://www.startnext.com/democracy/unterstuetzer/) and [contributors](https://github.com/demokratie-live/democracy-server/graphs/contributors) 88 | 89 | [node]: https://nodejs.org 90 | [yarn]: https://yarnpkg.com 91 | [js]: https://developer.mozilla.org/docs/Web/JavaScript 92 | -------------------------------------------------------------------------------- /src/graphql/resolvers/Deputy.ts: -------------------------------------------------------------------------------- 1 | import { MongooseFilterQuery } from 'mongoose'; 2 | import { parseResolveInfo } from 'graphql-parse-resolve-info'; 3 | import { Resolvers, VoteSelection } from '../../generated/graphql'; 4 | import { 5 | IProcedure, 6 | DeputyModel, 7 | IDeputy, 8 | IDeputyVote, 9 | } from '@democracy-deutschland/democracy-common'; 10 | import { reduce } from 'p-iteration'; 11 | 12 | const DeputyApi: Resolvers = { 13 | Query: { 14 | deputiesOfConstituency: async (parent, { constituency, directCandidate = false }) => { 15 | const query: MongooseFilterQuery = { 16 | constituency, 17 | }; 18 | if (directCandidate) { 19 | // returns only directCandidate 20 | query.directCandidate = true; 21 | } 22 | return DeputyModel.find(query); 23 | }, 24 | }, 25 | Deputy: { 26 | totalProcedures: ({ votes }) => votes.length, 27 | procedures: async ( 28 | { votes }, 29 | { procedureIds, offset = 0, pageSize = 9999999 }, 30 | { ProcedureModel }, 31 | info, 32 | ) => { 33 | global.Log.graphql('Deputy.field.procedures'); 34 | const requestedFields = parseResolveInfo(info); 35 | let didRequestOnlyProcedureId = false; 36 | if ( 37 | requestedFields && 38 | requestedFields.name === 'procedures' && 39 | 'procedure' in requestedFields.fieldsByTypeName.DeputyProcedure && 40 | 'procedureId' in 41 | requestedFields.fieldsByTypeName.DeputyProcedure.procedure.fieldsByTypeName.Procedure && 42 | Object.keys( 43 | requestedFields.fieldsByTypeName.DeputyProcedure.procedure.fieldsByTypeName.Procedure, 44 | ).length === 1 45 | ) { 46 | didRequestOnlyProcedureId = true; 47 | } 48 | 49 | // if procedureIds is given filter procedures to given procedureIds 50 | const filteredVotes = votes.filter(({ procedureId: pId }) => 51 | procedureIds ? procedureIds.includes(pId) : true, 52 | ); 53 | 54 | // flattern procedureId's 55 | const procedureIdsSelected = filteredVotes.map(({ procedureId }) => procedureId); 56 | 57 | // get needed procedure Data only from votes object 58 | if (didRequestOnlyProcedureId) { 59 | const returnValue = reduce< 60 | IDeputyVote, 61 | { 62 | procedure: Pick; 63 | decision: VoteSelection; 64 | }[] 65 | >( 66 | filteredVotes, 67 | async (prev, { procedureId, decision }) => { 68 | const procedure = { procedureId }; 69 | if (procedure) { 70 | const deputyProcedure = { 71 | procedure, 72 | decision, 73 | }; 74 | 75 | return [...prev, deputyProcedure]; 76 | } 77 | return prev; 78 | }, 79 | [], 80 | ).then((r) => r.slice(offset as number, (offset as number) + (pageSize as number))); 81 | // .slice(offset, offset + pageSize); 82 | 83 | return returnValue; 84 | } 85 | 86 | if (!offset) { 87 | offset = 0; 88 | } 89 | 90 | // if need more procedure data get procedure object from database 91 | const procedures = await ProcedureModel.find({ procedureId: { $in: procedureIdsSelected } }) 92 | .sort({ 93 | voteDate: -1, 94 | title: 1, 95 | }) 96 | .limit(pageSize || 9999999) 97 | .skip(offset); 98 | 99 | const result = await Promise.all( 100 | procedures.map(async (procedure) => { 101 | const p = await filteredVotes.find( 102 | ({ procedureId }) => procedure.procedureId === procedureId, 103 | ); 104 | return { 105 | decision: p?.decision, 106 | procedure: { ...procedure.toObject(), activityIndex: undefined, voted: undefined }, 107 | }; 108 | }), 109 | ); 110 | 111 | return result; 112 | }, 113 | }, 114 | }; 115 | 116 | export default DeputyApi; 117 | -------------------------------------------------------------------------------- /src/data/conference-weeks.ts: -------------------------------------------------------------------------------- 1 | // TODO replace this with a scraper for automatiation 2 | 3 | // convert german date to js Date 4 | function parseDate(input: string): Date { 5 | const parts = input.match(/(\d+)/g); 6 | if (parts) { 7 | const date = new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0])); 8 | 9 | // fix german time 10 | date.setHours(date.getHours() + 2); 11 | return date; 12 | } 13 | throw new Error('Error in conference-weeks'); 14 | } 15 | 16 | const getWeekNumber = (d: Date) => { 17 | // Copy date so don't modify original 18 | const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); 19 | // Set to nearest Thursday: current date + 4 - current day number 20 | // Make Sunday's day number 7 21 | date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7)); 22 | // Get first day of year 23 | const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); 24 | // Calculate full weeks to nearest Thursday 25 | const weekNo = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); 26 | // Return array of year and week number 27 | // return [date.getUTCFullYear(), weekNo]; 28 | return weekNo; 29 | }; 30 | 31 | const conferenceWeeks = [ 32 | { 33 | start: parseDate('13.01.2020'), 34 | end: parseDate('17.01.2020'), 35 | }, 36 | { 37 | start: parseDate('27.01.2020'), 38 | end: parseDate('31.01.2020'), 39 | }, 40 | { 41 | start: parseDate('10.02.2020'), 42 | end: parseDate('14.02.2020'), 43 | }, 44 | { 45 | start: parseDate('02.03.2020'), 46 | end: parseDate('06.03.2020'), 47 | }, 48 | { 49 | start: parseDate('09.03.2020'), 50 | end: parseDate('13.03.2020'), 51 | }, 52 | { 53 | start: parseDate('23.03.2020'), 54 | end: parseDate('27.03.2020'), 55 | }, 56 | { 57 | start: parseDate('20.04.2020'), 58 | end: parseDate('24.04.2020'), 59 | }, 60 | { 61 | start: parseDate('04.05.2020'), 62 | end: parseDate('07.05.2020'), 63 | }, 64 | { 65 | start: parseDate('11.05.2020'), 66 | end: parseDate('15.05.2020'), 67 | }, 68 | { 69 | start: parseDate('25.05.2020'), 70 | end: parseDate('29.05.2020'), 71 | }, 72 | { 73 | start: parseDate('15.06.2020'), 74 | end: parseDate('19.06.2020'), 75 | }, 76 | { 77 | start: parseDate('29.06.2020'), 78 | end: parseDate('03.07.2020'), 79 | }, 80 | { 81 | start: parseDate('07.09.2020'), 82 | end: parseDate('11.09.2020'), 83 | }, 84 | { 85 | start: parseDate('14.09.2020'), 86 | end: parseDate('18.09.2020'), 87 | }, 88 | { 89 | start: parseDate('28.09.2020'), 90 | end: parseDate('02.10.2020'), 91 | }, 92 | { 93 | start: parseDate('05.10.2020'), 94 | end: parseDate('09.10.2020'), 95 | }, 96 | { 97 | start: parseDate('26.10.2020'), 98 | end: parseDate('30.10.2020'), 99 | }, 100 | { 101 | start: parseDate('02.11.2020'), 102 | end: parseDate('06.11.2020'), 103 | }, 104 | { 105 | start: parseDate('16.11.2020'), 106 | end: parseDate('20.11.2020'), 107 | }, 108 | { 109 | start: parseDate('23.11.2020'), 110 | end: parseDate('27.11.2020'), 111 | }, 112 | { 113 | start: parseDate('07.12.2020'), 114 | end: parseDate('11.12.2020'), 115 | }, 116 | { 117 | start: parseDate('14.12.2020'), 118 | end: parseDate('18.12.2020'), 119 | }, 120 | ]; 121 | 122 | // return the current or next conference week 123 | export const getCurrentConferenceWeek = () => { 124 | const curDate = new Date(); 125 | 126 | // find actual or return undefined 127 | const currentConferenceWeek = conferenceWeeks.find(({ start, end }) => { 128 | return curDate > start && curDate < end; 129 | }); 130 | // if there is one running return 131 | if (currentConferenceWeek) { 132 | return { ...currentConferenceWeek, calendarWeek: getWeekNumber(currentConferenceWeek.start) }; 133 | } 134 | 135 | // else return next conference week 136 | const nextConferenceWeek = conferenceWeeks.find(({ start }) => { 137 | return curDate < start; 138 | }); 139 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 140 | return { ...nextConferenceWeek, calendarWeek: getWeekNumber(nextConferenceWeek!.start) }; 141 | }; 142 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 0.2.32 4 | 5 | - [Fix] WOM Constituency fix sort procedures 6 | 7 | ### 0.2.31 8 | 9 | - [Changed] add procedureId as id type for vote mutation 10 | 11 | ### 0.2.24 12 | 13 | - [Fix] dataloader procedure id handling 14 | 15 | ### 0.2.22 16 | 17 | - [Changed] add dataloader to graphql resolver procedures.voted 18 | 19 | ### 0.2.13 20 | 21 | - [Changed] decouple cronjob push-send-queued 22 | 23 | ### 0.2.10 - 0.2.12 24 | 25 | - [Changed] decouple cronjobs 26 | 27 | ### 0.2.9 28 | 29 | - [Fix] use mongoose instance from democracy-common 30 | 31 | ### 0.2.8 32 | 33 | - [Changed] start to decouple cronjobs 34 | 35 | ### 0.2.5 & 0.2.6 & 0.2.7 36 | 37 | - [NoChange] Tried successless to fix 1.2.2 crash 38 | 39 | ### 0.2.4 40 | 41 | - [Changed] Throw more clear errors on create new device error 42 | 43 | ### 0.2.3 44 | 45 | - [Changed] remove unnecessary console logs 46 | 47 | ### 0.2.2 48 | 49 | - [Changed] allow file path AND string for APN key 50 | 51 | ### 0.2.1 52 | 53 | - [FIX] Auto reconnect mongo 54 | 55 | ### 0.2.0 56 | 57 | - [Changed] Convert js to typescript 58 | 59 | ### 0.1.24 60 | 61 | - [FIX] Push for vote results 62 | 63 | ### 0.1.23 64 | 65 | - [FIX] Push queue memory issue 66 | 67 | ### 0.1.22 68 | 69 | - [FIX] Push memory issue 70 | 71 | ### 0.1.21 72 | 73 | - [ADD] conferenceWeek api endpoint 74 | 75 | ### 0.1.20 76 | 77 | - [FIX] Named Polls import 78 | 79 | ### 0.1.19 80 | 81 | - [FIX] Memory issue by disabling apollo query cache 82 | 83 | ### 0.1.18 84 | 85 | - [Changed] Return new list Types for Procedures 86 | - [FIX] Android: Fix Push notifications 87 | 88 | ### 0.1.17 89 | 90 | - [Added] Query CommunityVotes for browser version 91 | 92 | ### 0.1.16 93 | 94 | - [Fix] Named polls party names 95 | - [Added] Queries for statistic 96 | 97 | ### 0.1.15 98 | 99 | - [Changed] Filter performance for not-/voted 100 | - [Removed] Deprecated graphql field goverment [#438](https://github.com/demokratie-live/democracy-client/issues/438) 101 | - [Fixed] Search Button fix [#248](https://github.com/demokratie-live/democracy-client/issues/248) 102 | - [Fix] Remove id from Procedure.voteResults.PartyVotes subobject to fix unnecessary Push notifications 103 | - [Fix] more dynamic connection whitelisting 104 | - [Added] Catch all errors and log them 105 | 106 | ### 0.1.14 107 | 108 | - [Changed] JWT Header based authentification 109 | - [Added] DEBUG environment variable 110 | - [Added] Permissions for User-only and VerifiedUser-only requests 111 | - [Added] IP-Whitelist controll for Bundestag.io hooks 112 | - [Added] SMS Verification 113 | - [Added] Logger 114 | - [Added] FractionResults 115 | 116 | ### 0.1.13 117 | 118 | - [Add] Sorting for Procedures 119 | - [GraphQL] add Query: getProceduresById 120 | - [Fixed] Start even if no valid APPLE_APN_KEY is present [#462](https://github.com/demokratie-live/democracy-client/issues/462) 121 | - [Fixed] add internal lane topic for push notifications 122 | 123 | ### 0.1.12 124 | 125 | - [Changed] scrape bt-agenda `Überwiesen` show time 126 | - get estimated vote result from bundestagio 127 | - fix sorting for list (voteDate) 128 | - [Added] Add support for scraping currentState history 129 | - [Search] Use elastic-search server [#248](https://github.com/demokratie-live/democracy-client/issues/248) 130 | 131 | ### 0.1.11 132 | 133 | - handle removed vote results [#325](https://github.com/demokratie-live/democracy-client/issues/325) 134 | 135 | ### 0.1.10 136 | 137 | - lock graphiql via config 138 | 139 | ### 0.1.9 140 | 141 | - webhook 142 | - do a daily resync with the bundestag.io server 143 | - removed count as integrity measurement 144 | 145 | ### 0.1.8 146 | 147 | - graphQL 148 | - also provide votedGoverment(votedGovernment) for backwards compatibility (client <= 0.7.5) 149 | 150 | ### 0.1.7 151 | 152 | - graphQL 153 | - always import the correct voteDate for Anträge & Gesetze 154 | 155 | ### 0.1.6 156 | 157 | - graphQL 158 | - Include "Abgelehnt" and "Angenommen" in Voting-List & unified procedureState definition [#306](https://github.com/demokratie-live/democracy-client/issues/306) 159 | - Resync with bundestag.io on procedures gte 19 160 | - Parameterized the period to be displayed/used 161 | 162 | ### 0.1.5 163 | 164 | - pushNotifications 165 | - Disabled pushNotifications due to non-functionality 166 | 167 | ### 0.1.4 168 | 169 | - graphQL 170 | - Procedure resolver: procedures 171 | - Fix order ( first votedate and add by last update ) [#280](https://github.com/demokratie-live/democracy-client/issues/280) 172 | -------------------------------------------------------------------------------- /src/express/auth/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | import jwt from 'jsonwebtoken'; 3 | import crypto from 'crypto'; 4 | import { Response, NextFunction } from 'express'; 5 | import CONFIG from '../../config'; 6 | import { 7 | UserModel, 8 | DeviceModel, 9 | PhoneModel, 10 | User, 11 | Device, 12 | Phone, 13 | } from '@democracy-deutschland/democracy-common'; 14 | import { ExpressReqContext } from '../../types/graphqlContext'; 15 | 16 | export const createTokens = async (user: string) => { 17 | const token = jwt.sign( 18 | { 19 | user, 20 | }, 21 | CONFIG.AUTH_JWT_SECRET, 22 | { 23 | expiresIn: CONFIG.AUTH_JWT_TTL, 24 | }, 25 | ); 26 | 27 | const refreshToken = jwt.sign( 28 | { 29 | user, 30 | }, 31 | CONFIG.AUTH_JWT_SECRET, 32 | { 33 | expiresIn: CONFIG.AUTH_JWT_REFRESH_TTL, 34 | }, 35 | ); 36 | 37 | return Promise.all([token, refreshToken]); 38 | }; 39 | 40 | const refreshTokens = async (refreshToken: string) => { 41 | // Verify Token 42 | try { 43 | jwt.verify(refreshToken, CONFIG.AUTH_JWT_SECRET); 44 | } catch (err) { 45 | global.Log.error(err); 46 | return {}; 47 | } 48 | // Decode Token 49 | let userid = null; 50 | const jwtUser = jwt.decode(refreshToken); 51 | if (jwtUser && typeof jwtUser === 'object' && jwtUser.user) { 52 | userid = jwtUser.user; 53 | } else { 54 | return {}; 55 | } 56 | // Validate UserData if an old User was set 57 | const user = await UserModel.findOne({ _id: userid }); 58 | 59 | if (!user) { 60 | return {}; 61 | } 62 | // global.Log.jwt(`JWT: Token Refresh for User: ${user._id}`); 63 | // Generate new Tokens 64 | const [newToken, newRefreshToken] = await createTokens(user._id); 65 | return { 66 | token: newToken, 67 | refreshToken: newRefreshToken, 68 | user, 69 | }; 70 | }; 71 | 72 | export const headerToken = async ({ 73 | res, 74 | token, 75 | refreshToken, 76 | }: { 77 | res: Response; 78 | token: string; 79 | refreshToken: string; 80 | }) => { 81 | res.set('Access-Control-Expose-Headers', 'x-token, x-refresh-token'); 82 | res.set('x-token', token); 83 | res.set('x-refresh-token', refreshToken); 84 | 85 | if (CONFIG.DEBUG) { 86 | res.cookie('debugToken', token, { maxAge: 900000, httpOnly: true }); 87 | res.cookie('debugRefreshToken', refreshToken, { maxAge: 900000, httpOnly: true }); 88 | } 89 | }; 90 | 91 | export const authMiddleware = async (req: ExpressReqContext, res: Response, next: NextFunction) => { 92 | global.Log.debug(`Server: Connection from: ${req.connection.remoteAddress}`); 93 | let token: string | null = 94 | req.headers['x-token'] || (CONFIG.DEBUG ? req.cookies.debugToken : null); 95 | // In some cases the old Client transmitts the token via authorization header as 'Bearer [token]' 96 | if (CONFIG.JWT_BACKWARD_COMPATIBILITY && !token && req.headers.authorization) { 97 | token = req.headers.authorization.substr(7); 98 | } 99 | const deviceHash: string | null = 100 | req.headers['x-device-hash'] || (CONFIG.DEBUG ? req.query.deviceHash || null : null); 101 | const phoneHash: string | null = 102 | req.headers['x-phone-hash'] || (CONFIG.DEBUG ? req.query.phoneHash || null : null); 103 | if (deviceHash || phoneHash) { 104 | // global.Log.jwt(`JWT: Credentials with DeviceHash(${deviceHash}) PhoneHash(${phoneHash})`); 105 | } 106 | 107 | let success = false; 108 | // Check existing JWT Session 109 | // If Credentials are also present use them instead 110 | if (token && !deviceHash) { 111 | // global.Log.jwt(`JWT: Token: ${token}`); 112 | try { 113 | const jwtUser: any = jwt.verify(token, CONFIG.AUTH_JWT_SECRET); 114 | const userid = jwtUser.user; 115 | // Set request variables 116 | req.user = await UserModel.findOne({ _id: userid }); 117 | if (req.user) { 118 | if (req.user.device) { 119 | req.device = await DeviceModel.findOne({ _id: req.user.device }); 120 | } 121 | if (req.user.phone) { 122 | req.phone = await PhoneModel.findOne({ _id: req.user.phone }); 123 | } 124 | // Set new timestamps 125 | req.user.markModified('updatedAt'); 126 | await req.user.save(); 127 | if (req.device) { 128 | req.device.markModified('updatedAt'); 129 | await req.device.save(); 130 | } 131 | if (req.phone) { 132 | req.phone.markModified('updatedAt'); 133 | await req.phone.save(); 134 | } 135 | } 136 | success = true; 137 | // global.Log.jwt(`JWT: Token valid: ${token}`); 138 | } catch (err) { 139 | global.Log.error(err); 140 | // Check for JWT Refresh Ability 141 | global.Log.jwt(`JWT: Token Error: ${err}`); 142 | const refreshToken = 143 | req.headers['x-refresh-token'] || (CONFIG.DEBUG ? req.cookies.debugRefreshToken : null); 144 | const newTokens = await refreshTokens(refreshToken); 145 | if (newTokens.token && newTokens.refreshToken) { 146 | headerToken({ res, token: newTokens.token, refreshToken: newTokens.refreshToken }); 147 | // Set request variables 148 | req.user = newTokens.user; 149 | if (req.user) { 150 | if (req.user.device) { 151 | req.device = await DeviceModel.findOne({ _id: req.user.device }); 152 | } 153 | if (req.user.phone) { 154 | req.phone = await PhoneModel.findOne({ _id: req.user.phone }); 155 | } 156 | // Set new timestamps 157 | req.user.markModified('updatedAt'); 158 | await req.user.save(); 159 | if (req.device) { 160 | req.device.markModified('updatedAt'); 161 | await req.device.save(); 162 | } 163 | if (req.phone) { 164 | req.phone.markModified('updatedAt'); 165 | await req.phone.save(); 166 | } 167 | } 168 | success = true; 169 | // global.Log.jwt(`JWT: Token Refresh (t): ${newTokens.token}`); 170 | // global.Log.jwt(`JWT: Token Refresh (r): ${newTokens.refreshToken}`); 171 | } 172 | } 173 | } 174 | // Login 175 | if (!success) { 176 | let user: User | null = null; 177 | let device: Device | null = null; 178 | let phone: Phone | null = null; 179 | if (deviceHash) { 180 | // global.Log.jwt('JWT: Credentials present'); 181 | // User 182 | device = await DeviceModel.findOne({ 183 | deviceHash: crypto.createHash('sha256').update(deviceHash).digest('hex'), 184 | }); 185 | phone = phoneHash 186 | ? await PhoneModel.findOne({ 187 | phoneHash: crypto.createHash('sha256').update(phoneHash).digest('hex'), 188 | }) 189 | : null; 190 | user = await UserModel.findOne({ device: device, phone: phone }); 191 | if (!user) { 192 | // global.Log.jwt('JWT: Create new User'); 193 | 194 | device = await DeviceModel.findOne({ 195 | deviceHash: crypto.createHash('sha256').update(deviceHash).digest('hex'), 196 | }); 197 | // Device 198 | if (!device) { 199 | device = new DeviceModel({ 200 | deviceHash: crypto.createHash('sha256').update(deviceHash).digest('hex'), 201 | }); 202 | await device.save().catch((e) => { 203 | console.log(e); 204 | throw new Error('Error: Save new Device'); 205 | }); 206 | } 207 | 208 | // Create user 209 | user = new UserModel({ device, phone }); 210 | await user.save(); 211 | } 212 | // global.Log.jwt(`JWT: Token New for User: ${user._id}`); 213 | const [createToken, createRefreshToken] = await createTokens(user._id); 214 | headerToken({ res, token: createToken, refreshToken: createRefreshToken }); 215 | // Set new timestamps 216 | user.markModified('updatedAt'); 217 | await user.save(); 218 | if (device) { 219 | device.markModified('updatedAt'); 220 | await device.save(); 221 | } 222 | if (phone) { 223 | phone.markModified('updatedAt'); 224 | await phone.save(); 225 | } 226 | // global.Log.jwt(`JWT: Token New (t): ${createToken}`); 227 | // global.Log.jwt(`JWT: Token New (r): ${createRefreshToken}`); 228 | } 229 | // Set request variables 230 | req.user = user; 231 | req.device = device; 232 | req.phone = phone; 233 | } 234 | next(); 235 | }; 236 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2018] [DEMOCRACY Deutschland e.V.] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/services/sms/index.ts: -------------------------------------------------------------------------------- 1 | import request from 'request'; 2 | import _ from 'lodash'; 3 | 4 | import CONFIG from '../../config'; 5 | 6 | export const statusSMS = async (SMSID: string): Promise<{ succeeded: boolean; code: number }> => { 7 | if (CONFIG.SMS_SIMULATE) { 8 | return { succeeded: true, code: 1 }; 9 | } 10 | 11 | const url = 'https://www.smsflatrate.net/status.php'; 12 | 13 | const qs = { 14 | id: SMSID, 15 | }; 16 | 17 | return new Promise<{ succeeded: boolean; code: number }>((resolve, reject) => { 18 | request({ url, qs }, (err, response, body) => { 19 | if (err) { 20 | global.Log.error(JSON.stringify(err)); 21 | reject(err); 22 | } 23 | const code = parseInt(body, 10); 24 | switch (code) { 25 | case 100: 26 | // Standard Rückgabewert // Standard return value 27 | // SMS erfolgreich an das Gateway übertragen 28 | // SMS successfully transferred to the gateway 29 | resolve({ succeeded: true, code }); 30 | break; 31 | // Zustellberichte / Return values (101-109) 32 | // Return values 101 - 109 for sms with status request only 33 | case 101: 34 | // SMS wurde zugestellt // SMS successfully dispatched 35 | resolve({ succeeded: true, code }); 36 | break; 37 | case 102: 38 | // SMS wurde noch nicht zugestellt(z.B.Handy aus oder temporär nicht erreichbar) 39 | // SMS not delivered yet(for example mobile phone is off or network temporarily 40 | // unavailable) 41 | resolve({ succeeded: false, code }); 42 | break; 43 | case 103: 44 | // SMS konnte vermutlich nicht zugestellt werden(Rufnummer falsch, SIM nicht aktiv) 45 | // SMS probably not delivered(wrong number, SIMcard not active) 46 | resolve({ succeeded: false, code }); 47 | break; 48 | case 104: 49 | // SMS konnte nach Ablauf von 48 Stunden noch immer nicht zugestellt werden. 50 | // Aus dem Rückgabewert 102 wird nach Ablauf von 2 Tagen der Status 104. 51 | // SMS could not be delivered within 48 hours. 52 | // The return value 102 changes to 104 after the 48 hours have passed. 53 | resolve({ succeeded: false, code }); 54 | break; 55 | case 109: 56 | // SMS ID abgelaufen oder ungültig(manuelle Status - Abfrage) 57 | // SMS ID expired or is invalid(for using manual status request) 58 | resolve({ succeeded: false, code }); 59 | break; 60 | // Zusätzliche Rückgabewerte 61 | case 110: 62 | // Falscher Schnittstellen - Key oder Ihr Account ist gesperrt 63 | // Wrong Gateway - Key or your account is locked 64 | resolve({ succeeded: false, code }); 65 | break; 66 | case 120: 67 | // Guthaben reicht nicht aus // Not enough credits 68 | resolve({ succeeded: false, code }); 69 | break; 70 | case 130: 71 | // Falsche Datenübergabe(z.B.Absender fehlt) 72 | // Incorrect data transfer(for example the Sender - ID is missing) 73 | resolve({ succeeded: false, code }); 74 | break; 75 | case 131: 76 | // Empfänger nicht korrekt // Receiver number is not correct 77 | resolve({ succeeded: false, code }); 78 | break; 79 | case 132: 80 | // Absender nicht korrekt // Sender-ID is not correct 81 | resolve({ succeeded: false, code }); 82 | break; 83 | case 133: 84 | // Nachrichtentext nicht korrekt // Text message not correct 85 | resolve({ succeeded: false, code }); 86 | break; 87 | case 140: 88 | // Falscher AppKey oder Ihr Account ist gesperrt 89 | // Wrong AppKey or your account is locked 90 | resolve({ succeeded: false, code }); 91 | break; 92 | case 150: 93 | // Sie haben versucht an eine internationale Handynummer eines Gateways, das 94 | // ausschließlich für den Versand nach Deutschland bestimmt ist, zu senden. 95 | // Bitte internationales Gateway oder Auto - Type - Funktion verwenden. 96 | // You have tried to send to an international phone number through a gateway determined to 97 | // handle german receivers only.Please use an international Gateway - Type or Auto - Type. 98 | resolve({ succeeded: false, code }); 99 | break; 100 | case 170: 101 | // Parameter „time =“ ist nicht korrekt.Bitte im Format: TT.MM.JJJJ - SS: MM oder 102 | // Parameter entfernen für sofortigen Versand. 103 | // Parameter "time =" is not correct.Please use the format: TT.MM.YYYY - SS: MM or delete 104 | // parameter for immediately dispatch. 105 | resolve({ succeeded: false, code }); 106 | break; 107 | case 171: 108 | // Parameter „time =“ ist zu weit in der Zukunft terminiert(max. 360 Tage) 109 | // Parameter "time =" is too far in the future(max. 360 days). 110 | resolve({ succeeded: false, code }); 111 | break; 112 | case 180: 113 | // Account noch nicht komplett freigeschaltet / Volumen - Beschränkung noch aktiv 114 | // Bitte im Kundencenter die Freischaltung beantragen, damit unbeschränkter 115 | // Nachrichtenversand möglich ist. 116 | resolve({ succeeded: false, code }); 117 | break; 118 | case 231: 119 | // Keine smsflatrate.net Gruppe vorhanden oder nicht korrekt 120 | // smsflatrate.net group is not available or not correct 121 | resolve({ succeeded: false, code }); 122 | break; 123 | case 404: 124 | // Unbekannter Fehler.Bitte dringend Support(ticket@smsflatrate.net) kontaktieren. 125 | // Unknown error.Please urgently contact support(ticket@smsflatrate.net). 126 | resolve({ succeeded: false, code }); 127 | break; 128 | default: 129 | resolve({ succeeded: false, code }); 130 | } 131 | }); 132 | }); 133 | }; 134 | 135 | export const sendSMS = async ( 136 | phone: string, 137 | code: string, 138 | ): Promise<{ 139 | status: boolean; 140 | statusCode: number; 141 | SMSID: string; 142 | }> => { 143 | if (CONFIG.SMS_SIMULATE) { 144 | return { status: true, statusCode: 100, SMSID: _.uniqueId() }; 145 | } 146 | const url = 'https://www.smsflatrate.net/schnittstelle.php'; 147 | 148 | const qs = { 149 | key: CONFIG.SMS_PROVIDER_KEY, 150 | from: 'DEMOCRACY', 151 | to: phone, 152 | text: `Hallo von DEMOCRACY. Dein Code lautet: ${code}`, 153 | type: 'auto10or11', 154 | status: '1', 155 | }; 156 | 157 | return new Promise((resolve, reject) => { 158 | request({ url, qs }, (err, response, body) => { 159 | let status = false; 160 | let statusCode = null; 161 | let SMSID = null; 162 | if (err) { 163 | reject(err); 164 | } 165 | const bodyResult = body.split(','); 166 | statusCode = parseInt(bodyResult[0], 10); 167 | SMSID = bodyResult[1]; // eslint-disable-line 168 | switch (statusCode) { 169 | case 100: 170 | // Standard Rückgabewert // Standard return value 171 | // SMS erfolgreich an das Gateway übertragen 172 | // SMS successfully transferred to the gateway 173 | status = true; 174 | break; 175 | // Zustellberichte / Return values (101-109) 176 | // Return values 101 - 109 for sms with status request only 177 | case 101: 178 | // SMS wurde zugestellt // SMS successfully dispatched 179 | status = true; 180 | break; 181 | case 102: 182 | // SMS wurde noch nicht zugestellt(z.B.Handy aus oder temporär nicht erreichbar) 183 | // SMS not delivered yet(for example mobile phone is off or network temporarily 184 | // unavailable) 185 | status = false; 186 | break; 187 | case 103: 188 | // SMS konnte vermutlich nicht zugestellt werden(Rufnummer falsch, SIM nicht aktiv) 189 | // SMS probably not delivered(wrong number, SIMcard not active) 190 | status = false; 191 | break; 192 | case 104: 193 | // SMS konnte nach Ablauf von 48 Stunden noch immer nicht zugestellt werden. 194 | // Aus dem Rückgabewert 102 wird nach Ablauf von 2 Tagen der Status 104. 195 | // SMS could not be delivered within 48 hours. 196 | // The return value 102 changes to 104 after the 48 hours have passed. 197 | status = false; 198 | break; 199 | case 109: 200 | // SMS ID abgelaufen oder ungültig(manuelle Status - Abfrage) 201 | // SMS ID expired or is invalid(for using manual status request) 202 | status = false; 203 | break; 204 | 205 | // Zusätzliche Rückgabewerte 206 | case 110: 207 | // Falscher Schnittstellen - Key oder Ihr Account ist gesperrt 208 | // Wrong Gateway - Key or your account is locked 209 | status = false; 210 | break; 211 | case 120: 212 | // Guthaben reicht nicht aus // Not enough credits 213 | status = false; 214 | break; 215 | case 130: 216 | // Falsche Datenübergabe(z.B.Absender fehlt) 217 | // Incorrect data transfer(for example the Sender - ID is missing) 218 | status = false; 219 | break; 220 | case 131: 221 | // Empfänger nicht korrekt // Receiver number is not correct 222 | status = false; 223 | break; 224 | case 132: 225 | // Absender nicht korrekt // Sender-ID is not correct 226 | status = false; 227 | break; 228 | case 133: 229 | // Nachrichtentext nicht korrekt // Text message not correct 230 | status = false; 231 | break; 232 | case 140: 233 | // Falscher AppKey oder Ihr Account ist gesperrt 234 | // Wrong AppKey or your account is locked 235 | status = false; 236 | break; 237 | case 150: 238 | // Sie haben versucht an eine internationale Handynummer eines Gateways, das 239 | // ausschließlich für den Versand nach Deutschland bestimmt ist, zu senden. 240 | // Bitte internationales Gateway oder Auto - Type - Funktion verwenden. 241 | // You have tried to send to an international phone number through a gateway determined to 242 | // handle german receivers only.Please use an international Gateway - Type or Auto - Type. 243 | status = false; 244 | break; 245 | case 170: 246 | // Parameter „time =“ ist nicht korrekt.Bitte im Format: TT.MM.JJJJ - SS: MM oder 247 | // Parameter entfernen für sofortigen Versand. 248 | // Parameter "time =" is not correct.Please use the format: TT.MM.YYYY - SS: MM or delete 249 | // parameter for immediately dispatch. 250 | status = false; 251 | break; 252 | case 171: 253 | // Parameter „time =“ ist zu weit in der Zukunft terminiert(max. 360 Tage) 254 | // Parameter "time =" is too far in the future(max. 360 days). 255 | status = false; 256 | break; 257 | case 180: 258 | // Account noch nicht komplett freigeschaltet / Volumen - Beschränkung noch aktiv 259 | // Bitte im Kundencenter die Freischaltung beantragen, damit unbeschränkter 260 | // Nachrichtenversand möglich ist. 261 | status = false; 262 | break; 263 | case 231: 264 | // Keine smsflatrate.net Gruppe vorhanden oder nicht korrekt 265 | // smsflatrate.net group is not available or not correct 266 | status = false; 267 | break; 268 | case 404: 269 | // Unbekannter Fehler.Bitte dringend Support(ticket@smsflatrate.net) kontaktieren. 270 | // Unknown error.Please urgently contact support(ticket@smsflatrate.net). 271 | status = false; 272 | break; 273 | default: 274 | status = false; 275 | } 276 | if (!status) { 277 | console.error('SMS Error', bodyResult); 278 | } 279 | resolve({ status, statusCode, SMSID }); 280 | }); 281 | }); 282 | }; 283 | -------------------------------------------------------------------------------- /src/graphql/resolvers/Device.ts: -------------------------------------------------------------------------------- 1 | import ms from 'ms'; 2 | import _ from 'lodash'; 3 | import crypto from 'crypto'; 4 | 5 | import CONFIG from '../../config'; 6 | import { createTokens, headerToken } from '../../express/auth'; 7 | import { sendSMS, statusSMS } from '../../services/sms'; 8 | import { Resolvers, NotificationSettings } from '../../generated/graphql'; 9 | 10 | const calculateResendTime = ({ 11 | latestCodeTime, 12 | codesCount, 13 | expires, 14 | }: { 15 | latestCodeTime: number; 16 | codesCount: number; 17 | expires: number; 18 | }): Date => 19 | new Date( 20 | Math.min( 21 | expires, 22 | latestCodeTime + 23 | (ms(CONFIG.SMS_VERIFICATION_CODE_RESEND_BASETIME) / 1000) ** codesCount * 1000, 24 | ), 25 | ); 26 | 27 | const DeviceApi: Resolvers = { 28 | Query: { 29 | notificationSettings: async (_parent, _args, { device }) => { 30 | global.Log.graphql('Device.query.notificationSettings'); 31 | const result: NotificationSettings = { 32 | ...device.notificationSettings, 33 | procedures: device.notificationSettings.procedures.map((procedure) => { 34 | if ('_id' in procedure) { 35 | return procedure._id; 36 | } 37 | return procedure; 38 | }), 39 | }; 40 | return result; 41 | }, 42 | }, 43 | 44 | Mutation: { 45 | // ************ 46 | // REQUEST CODE 47 | // ************ 48 | requestCode: async ( 49 | parent, 50 | { newPhone, oldPhoneHash }, 51 | { user, device, phone, PhoneModel, VerificationModel }, 52 | ) => { 53 | global.Log.graphql('Device.mutation.requestCode'); 54 | // Check for SMS Verification 55 | if (!CONFIG.SMS_VERIFICATION) { 56 | return { 57 | reason: 'SMS Verification is disabled!', 58 | succeeded: false, 59 | }; 60 | } 61 | 62 | if (!oldPhoneHash && user.isVerified()) { 63 | return { 64 | reason: 'You are already verified!', 65 | succeeded: false, 66 | }; 67 | } 68 | 69 | // check newPhone prefix & length, 3 prefix, min. length 10 70 | if (newPhone.substr(0, 3) !== '+49' || newPhone.length < 12) { 71 | return { 72 | reason: 73 | 'newPhone is invalid - does not have the required length of min. 12 digits or does not start with countrycode +49', 74 | succeeded: false, 75 | }; 76 | } 77 | 78 | // Check for invalid transfere 79 | const newPhoneHash = crypto.createHash('sha256').update(newPhone).digest('hex'); 80 | const newPhoneDBHash = crypto.createHash('sha256').update(newPhoneHash).digest('hex'); 81 | const oldPhoneDBHash = oldPhoneHash 82 | ? crypto.createHash('sha256').update(oldPhoneHash).digest('hex') 83 | : null; 84 | if (newPhoneHash === oldPhoneHash) { 85 | return { 86 | reason: 'newPhoneHash equals oldPhoneHash', 87 | succeeded: false, 88 | }; 89 | } 90 | 91 | // Check for valid oldPhoneHash 92 | if ((oldPhoneHash && !user.isVerified()) || (oldPhoneHash && !phone)) { 93 | return { 94 | reason: 'Provided oldPhoneHash is invalid', 95 | succeeded: false, 96 | }; 97 | } 98 | 99 | let verification = await VerificationModel.findOne({ 100 | phoneHash: newPhoneDBHash, 101 | }); 102 | if (!verification) { 103 | verification = new VerificationModel({ 104 | phoneHash: newPhoneDBHash, 105 | }); 106 | } 107 | 108 | // Genrate Code 109 | const minVal = 100000; 110 | const maxVal = 999999; 111 | 112 | let code: string = (Math.floor(Math.random() * (maxVal - minVal + 1)) + minVal).toString(); // eslint-disable-line 113 | if (CONFIG.SMS_SIMULATE) { 114 | code = '000000'; 115 | } 116 | 117 | const now = new Date(); 118 | // Check if there is still a valid Code 119 | const activeCode = verification.verifications?.find(({ expires }) => now < new Date(expires)); 120 | if (activeCode) { 121 | // *********** 122 | // Resend Code 123 | // *********** 124 | // Find Code Count & latest Code Time 125 | const codesCount = activeCode.codes?.length || 0; 126 | const latestCode = activeCode.codes?.reduce( 127 | (max, p) => (p.time > max.time ? p : max), 128 | activeCode.codes[0], 129 | ); 130 | 131 | // Check code time 132 | if ( 133 | latestCode && 134 | latestCode.time.getTime() + 135 | ms(CONFIG.SMS_VERIFICATION_CODE_RESEND_BASETIME) ** codesCount >= 136 | now.getTime() 137 | ) { 138 | return { 139 | reason: 'You have to wait till you can request another Code', 140 | resendTime: calculateResendTime({ 141 | latestCodeTime: latestCode.time.getTime(), 142 | codesCount, 143 | expires: new Date(activeCode.expires).getTime(), 144 | }), 145 | expireTime: activeCode.expires, 146 | succeeded: false, 147 | }; 148 | } 149 | 150 | // Validate that the Number has recieved the Code 151 | const smsstatus = await statusSMS(latestCode?.SMSID || ''); 152 | if (!smsstatus.succeeded && [102, 103].includes(smsstatus.code)) { 153 | console.log('DEBUG latestCode', { latestCode, activeCode }); 154 | return { 155 | reason: 'Your number seems incorrect, please correct it!', 156 | succeeded: false, 157 | }; 158 | } else if (!smsstatus.succeeded) { 159 | // TODO better error handling 160 | console.error('SMS ERROR', latestCode, smsstatus); 161 | } 162 | 163 | // Send SMS 164 | const { status, SMSID } = await sendSMS(newPhone, code); 165 | 166 | activeCode.codes?.push({ 167 | code, 168 | time: now, 169 | SMSID, 170 | }); 171 | await verification.save(); 172 | 173 | // Check Status here to make sure the Verification request is saved 174 | if (!status) { 175 | return { 176 | reason: 'Could not send SMS to given newPhone', 177 | succeeded: false, 178 | }; 179 | } 180 | 181 | return { 182 | succeeded: true, 183 | resendTime: calculateResendTime({ 184 | latestCodeTime: now.getTime(), 185 | codesCount: codesCount + 1, 186 | expires: new Date(activeCode.expires).getTime(), 187 | }), 188 | expireTime: activeCode.expires, 189 | }; 190 | // *********** 191 | // Resend Code 192 | // ********END 193 | } 194 | 195 | // Send SMS 196 | const { status, SMSID } = await sendSMS(newPhone, code); 197 | 198 | // Allow to create new user based on last usage 199 | const verificationPhone = await PhoneModel.findOne({ 200 | phoneHash: newPhoneDBHash, 201 | }); 202 | let allowNewUser = false; // Is only set if there was a user registered 203 | if ( 204 | verificationPhone && 205 | verificationPhone.updatedAt < 206 | new Date(now.getTime() - ms(CONFIG.SMS_VERIFICATION_NEW_USER_DELAY)) 207 | ) { 208 | // Older then 6 Months 209 | allowNewUser = true; 210 | } 211 | 212 | // Code expiretime 213 | const expires = new Date(now.getTime() + ms(CONFIG.SMS_VERIFICATION_CODE_TTL)); 214 | verification.verifications?.push({ 215 | deviceHash: device.deviceHash, 216 | oldPhoneHash: oldPhoneDBHash || '', 217 | codes: [{ code, time: now, SMSID }], 218 | expires, 219 | }); 220 | await verification.save(); 221 | 222 | // Check Status here to make sure the Verification request is saved 223 | if (!status) { 224 | return { 225 | reason: 'Could not send SMS to given newPhone', 226 | succeeded: false, 227 | }; 228 | } 229 | 230 | return { 231 | allowNewUser, 232 | resendTime: calculateResendTime({ 233 | latestCodeTime: now.getTime(), 234 | codesCount: 1, 235 | expires: expires.getTime(), 236 | }), 237 | expireTime: expires, 238 | succeeded: true, 239 | }; 240 | }, 241 | 242 | // ******************** 243 | // REQUEST VERIFICATION 244 | // ******************** 245 | requestVerification: async ( 246 | parent, 247 | { code, newPhoneHash, newUser }, 248 | { res, device, phone, UserModel, PhoneModel, VerificationModel }, 249 | ) => { 250 | global.Log.graphql('Device.mutation.requestVerification'); 251 | // Check for SMS Verification 252 | if (!CONFIG.SMS_VERIFICATION) { 253 | return { 254 | reason: 'SMS Verification is disabled!', 255 | succeeded: false, 256 | }; 257 | } 258 | 259 | const newPhoneDBHash = crypto.createHash('sha256').update(newPhoneHash).digest('hex'); 260 | // Find Verification 261 | const verifications = await VerificationModel.findOne({ 262 | phoneHash: newPhoneDBHash, 263 | }); 264 | if (!verifications) { 265 | return { 266 | reason: 'Could not find verification request', 267 | succeeded: false, 268 | }; 269 | } 270 | 271 | // Find Code 272 | const now = new Date(); 273 | const verification = verifications.verifications?.find( 274 | ({ expires, codes }) => now < expires && codes?.find(({ code: dbCode }) => code === dbCode), 275 | ); 276 | 277 | // Code valid? 278 | if (!verification) { 279 | return { 280 | reason: 'Invalid Code or Code expired', 281 | succeeded: false, 282 | }; 283 | } 284 | 285 | // Check device 286 | if (device.deviceHash !== verification.deviceHash) { 287 | return { 288 | reason: 'Code requested from another Device', 289 | succeeded: false, 290 | }; 291 | } 292 | 293 | // User has phoneHash, but no oldPhoneHash? 294 | if ( 295 | verification.oldPhoneHash && 296 | (!phone || 297 | (typeof phone.phoneHash === 'string' && phone.phoneHash !== verification.oldPhoneHash)) 298 | ) { 299 | return { 300 | reason: 'User phoneHash and oldPhoneHash inconsistent', 301 | succeeded: false, 302 | }; 303 | } 304 | 305 | // Invalidate Code 306 | verifications.verifications = verifications.verifications?.map((obj) => { 307 | if (obj._id === verification._id) { 308 | obj.expires = now; 309 | } 310 | return obj; 311 | }); 312 | await verifications.save(); 313 | 314 | // New Phone 315 | let newPhone = await PhoneModel.findOne({ 316 | phoneHash: newPhoneDBHash, 317 | }); 318 | // Phone exists & New User? 319 | if ( 320 | newPhone && 321 | newUser && 322 | newPhone.updatedAt < new Date(now.getTime() - ms(CONFIG.SMS_VERIFICATION_NEW_USER_DELAY)) 323 | ) { 324 | // Allow new User - Invalidate newPhone 325 | newPhone.phoneHash = `Invalidated at '${now}': ${newPhone.phoneHash}`; 326 | await newPhone.save(); 327 | newPhone = null; 328 | } 329 | 330 | // oldPhoneHash and no newPhone 331 | if (verification.oldPhoneHash && !newPhone) { 332 | // Find old Phone 333 | const oldPhone = await PhoneModel.findOne({ phoneHash: verification.oldPhoneHash }); 334 | // We found an old phone and no new User is requested 335 | if ( 336 | oldPhone && 337 | (!newUser || 338 | oldPhone.updatedAt >= 339 | new Date(now.getTime() - ms(CONFIG.SMS_VERIFICATION_NEW_USER_DELAY))) 340 | ) { 341 | newPhone = oldPhone; 342 | newPhone.phoneHash = newPhoneHash; 343 | await newPhone.save(); 344 | } 345 | } 346 | 347 | // Still no newPhone? 348 | if (!newPhone) { 349 | // Create Phone 350 | newPhone = new PhoneModel({ 351 | phoneHash: newPhoneDBHash, 352 | }); 353 | await newPhone.save(); 354 | } 355 | 356 | // Delete Existing User 357 | await UserModel.deleteOne({ device: device._id, phone: newPhone._id }); 358 | 359 | // Unverify all of the same device or phone 360 | await UserModel.update( 361 | { $or: [{ device: device._id }, { phone: newPhone._id }] }, 362 | { verified: false }, 363 | { multi: true }, 364 | ); 365 | 366 | // Create new User and update session User 367 | const saveUser = await UserModel.create({ 368 | device: device._id, 369 | phone: newPhone._id, 370 | verified: true, 371 | }); 372 | await saveUser.save(); 373 | // This should not be necessary since the call ends here - but you never know 374 | // phone = newPhone; 375 | 376 | // Send new tokens since user id has been changed 377 | const [token, refreshToken] = await createTokens(saveUser._id); 378 | await headerToken({ res, token, refreshToken }); 379 | 380 | return { 381 | succeeded: true, 382 | }; 383 | }, 384 | 385 | addToken: async (parent, { token, os }, { device }) => { 386 | global.Log.graphql('Device.mutation.addToken'); 387 | if (!device.pushTokens.some((t) => t.token === token)) { 388 | device.pushTokens.push({ token, os }); 389 | await device.save(); 390 | } 391 | return { 392 | succeeded: true, 393 | }; 394 | }, 395 | 396 | updateNotificationSettings: async ( 397 | parent, 398 | { 399 | enabled, 400 | disableUntil, 401 | procedures, 402 | tags, 403 | newVote, 404 | newPreperation, 405 | conferenceWeekPushs, 406 | voteConferenceWeekPushs, 407 | voteTOP100Pushs, 408 | outcomePushs, 409 | outcomePushsEnableOld, 410 | }, 411 | { phone, device, DeviceModel, VoteModel }, 412 | ) => { 413 | global.Log.graphql('Device.mutation.updateNotificationSettings'); 414 | 415 | device.notificationSettings = { 416 | ...device.notificationSettings, 417 | ..._.omitBy( 418 | { 419 | enabled, 420 | disableUntil, 421 | procedures, 422 | tags, 423 | newVote, 424 | newPreperation, 425 | // traversal of old settings -> new settings 426 | conferenceWeekPushs, 427 | voteConferenceWeekPushs: 428 | newVote && !voteConferenceWeekPushs ? newVote : voteConferenceWeekPushs, 429 | voteTOP100Pushs: newPreperation && !voteTOP100Pushs ? newPreperation : voteTOP100Pushs, 430 | // new setting 431 | outcomePushs, 432 | }, 433 | _.isNil, 434 | ), 435 | }; 436 | 437 | await device.save(); 438 | 439 | // Enable all old Procedures to be pushed 440 | // TODO here we use two write Operations since $addToSet is used 441 | // to ensure uniqueness of items - this can be done serverside aswell 442 | // reducing write operations - but required ObjectId comparison 443 | if (outcomePushs && outcomePushsEnableOld) { 444 | const actor = CONFIG.SMS_VERIFICATION ? phone._id : device._id; 445 | const kind = CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device'; 446 | const votedProcedures = await VoteModel.find( 447 | { type: kind, 'voters.voter': actor }, 448 | { procedure: 1 }, 449 | ); 450 | 451 | const proceduresOld = votedProcedures.map(({ procedure }) => procedure); 452 | 453 | await DeviceModel.updateOne( 454 | { _id: device._id }, 455 | { $addToSet: { 'notificationSettings.procedures': { $each: proceduresOld } } }, 456 | ); 457 | // TODO this additional read operation is also not nessecarily required 458 | // if the calculation is done serverside 459 | device = await DeviceModel.findOne({ _id: device._id }).then((d) => 460 | d ? d.toObject() : null, 461 | ); 462 | } 463 | 464 | const result: NotificationSettings = { 465 | ...device.notificationSettings, 466 | procedures: device.notificationSettings.procedures.map((procedure) => { 467 | if ('_id' in procedure) { 468 | return procedure._id; 469 | } 470 | return procedure; 471 | }), 472 | }; 473 | return result; 474 | }, 475 | 476 | toggleNotification: async (parent, { procedureId }, { device, ProcedureModel }) => { 477 | global.Log.graphql('Device.mutation.toggleNotification'); 478 | const procedure = await ProcedureModel.findOne({ procedureId }); 479 | if (procedure) { 480 | const index = device.notificationSettings.procedures.indexOf(procedure?._id); 481 | let notify; 482 | if (index > -1) { 483 | notify = false; 484 | device.notificationSettings.procedures.splice(index, 1); 485 | } else { 486 | notify = true; 487 | device.notificationSettings.procedures.push(procedure._id); 488 | } 489 | await device.save(); 490 | return { ...procedure.toObject(), notify }; 491 | } 492 | }, 493 | }, 494 | }; 495 | 496 | export default DeviceApi; 497 | -------------------------------------------------------------------------------- /src/graphql/resolvers/Vote.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | 3 | import { PROCEDURE as PROCEDURE_DEFINITIONS } from '@democracy-deutschland/bundestag.io-definitions'; 4 | import { Vote, IDeputy } from '@democracy-deutschland/democracy-common'; 5 | import { Types } from 'mongoose'; 6 | import { MongooseFilterQuery } from 'mongoose'; 7 | import CONFIG from '../../config'; 8 | import PROCEDURE_STATES from '../../config/procedureStates'; 9 | import { Resolvers, VoteSelection } from '../../generated/graphql'; 10 | import { GraphQlContext } from '../../types/graphqlContext'; 11 | import ActivityApi from './Activity'; 12 | 13 | const queryVotes = async ( 14 | _parent: any, 15 | { procedure, constituencies }: { procedure: string; constituencies: string[] | null | undefined }, 16 | { 17 | VoteModel, 18 | device, 19 | phone, 20 | }: { 21 | VoteModel: GraphQlContext['VoteModel']; 22 | device: GraphQlContext['device']; 23 | phone: GraphQlContext['phone']; 24 | }, 25 | ) => { 26 | global.Log.graphql('Vote.query.votes'); 27 | // Has user voted? 28 | const voted = await VoteModel.findOne({ 29 | procedure, 30 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 31 | voters: { 32 | voter: CONFIG.SMS_VERIFICATION ? phone : device, 33 | }, 34 | }); 35 | 36 | // Find global result(cache), not including constituencies 37 | const votesGlobal = await VoteModel.aggregate([ 38 | // Find Procedure, including type; results in up to two objects for state 39 | { 40 | $match: { 41 | procedure, 42 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 43 | }, 44 | }, 45 | // Sum both objects (state) 46 | { 47 | $group: { 48 | _id: '$procedure', 49 | yes: { $sum: '$votes.cache.yes' }, 50 | no: { $sum: '$votes.cache.no' }, 51 | abstain: { $sum: '$votes.cache.abstain' }, 52 | }, 53 | }, 54 | // Add voted state from previous query 55 | { $addFields: { voted: !!voted } }, 56 | // Build correct result 57 | { 58 | $project: { 59 | _id: true, 60 | voted: true, 61 | voteResults: { 62 | yes: '$yes', 63 | no: '$no', 64 | abstination: '$abstain', 65 | total: { $add: ['$yes', '$no', '$abstain'] }, 66 | }, 67 | }, 68 | }, 69 | ]); 70 | 71 | // Find constituency results if constituencies are given 72 | const votesConstituencies = 73 | (constituencies && constituencies.length > 0) || 74 | constituencies === undefined || 75 | constituencies === null 76 | ? await VoteModel.aggregate([ 77 | // Find Procedure, including type; results in up to two objects for state 78 | { 79 | $match: { 80 | procedure, 81 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 82 | }, 83 | }, 84 | // Filter correct constituency 85 | { 86 | $project: { 87 | votes: { 88 | constituencies: { 89 | $filter: { 90 | input: '$votes.constituencies', 91 | as: 'constituency', 92 | cond: !constituencies 93 | ? true // Return all Constituencies if constituencies param is not given 94 | : { $in: ['$$constituency.constituency', constituencies] }, // Filter Constituencies if an array is given 95 | }, 96 | }, 97 | }, 98 | }, 99 | }, 100 | // Unwind constituencies for sum, but preserve null 101 | { 102 | $unwind: { 103 | path: '$votes.constituencies', 104 | preserveNullAndEmptyArrays: true, 105 | }, 106 | }, 107 | // Sum both objects (state) 108 | { 109 | $group: { 110 | _id: '$votes.constituencies.constituency', 111 | yes: { $sum: '$votes.constituencies.yes' }, 112 | no: { $sum: '$votes.constituencies.no' }, 113 | abstain: { $sum: '$votes.constituencies.abstain' }, 114 | }, 115 | }, 116 | // Build correct result 117 | { 118 | $project: { 119 | _id: false, 120 | constituency: '$_id', 121 | yes: '$yes', 122 | no: '$no', 123 | abstination: '$abstain', 124 | total: { $add: ['$yes', '$no', '$abstain'] }, 125 | }, 126 | }, 127 | ]) 128 | // TODO Change query to make the filter obsolet (preserveNullAndEmptyArrays) 129 | // Remove elements with property constituency: null (of no votes on it) 130 | .then((data) => data.filter(({ constituency }) => constituency)) 131 | : []; 132 | 133 | if (votesGlobal.length > 0) { 134 | votesGlobal[0].voteResults.constituencies = votesConstituencies; 135 | return votesGlobal[0]; 136 | } 137 | return { 138 | voted: false, 139 | voteResults: { yes: null, no: null, abstination: null, constituencies: [] }, 140 | }; 141 | }; 142 | 143 | const VoteApi: Resolvers = { 144 | Query: { 145 | // Used by App 146 | votes: async (_parent: any, { procedure, constituencies }, { VoteModel, device, phone }) => { 147 | return queryVotes(_parent, { procedure, constituencies }, { VoteModel, device, phone }); 148 | }, 149 | // Used by Browserverion -> TODO Remove 150 | communityVotes: async ( 151 | parent, 152 | { procedure: procedureId, constituencies }, 153 | { VoteModel, ProcedureModel }, 154 | ) => { 155 | global.Log.graphql('Vote.query.communityVotes'); 156 | const procedure = await ProcedureModel.findOne({ procedureId }, { _id: 1 }); 157 | if (!procedure) { 158 | throw new Error(`Procedure could not be found. ID: ${procedureId}`); 159 | } 160 | 161 | // Find global result(cache), not including constituencies 162 | const votesGlobal = await VoteModel.aggregate([ 163 | // Find Procedure 164 | { 165 | $match: { 166 | procedure: procedure._id, 167 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 168 | }, 169 | }, 170 | // Sum both objects (state) 171 | { 172 | $group: { 173 | _id: '$procedure', 174 | yes: { $sum: '$votes.cache.yes' }, 175 | no: { $sum: '$votes.cache.no' }, 176 | abstination: { $sum: '$votes.cache.abstain' }, 177 | }, 178 | }, 179 | // Remove _id from result 180 | { 181 | $project: { 182 | _id: false, 183 | }, 184 | }, 185 | ]); 186 | 187 | // Find constituency results if constituencies are given 188 | const votesConstituencies = 189 | (constituencies && constituencies.length > 0) || constituencies === undefined 190 | ? await VoteModel.aggregate([ 191 | // Find Procedure, including type; results in up to two objects for state 192 | { 193 | $match: { 194 | procedure: procedure._id, 195 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 196 | }, 197 | }, 198 | // Filter correct constituency 199 | { 200 | $project: { 201 | votes: { 202 | constituencies: { 203 | $filter: { 204 | input: '$votes.constituencies', 205 | as: 'constituency', 206 | cond: !constituencies 207 | ? true // Return all Constituencies if constituencies param is not given 208 | : { $in: ['$$constituency.constituency', constituencies] }, // Filter Constituencies if an array is given 209 | }, 210 | }, 211 | }, 212 | }, 213 | }, 214 | // Unwind constituencies for sum, but preserve null 215 | { 216 | $unwind: { 217 | path: '$votes.constituencies', 218 | preserveNullAndEmptyArrays: true, 219 | }, 220 | }, 221 | // Sum both objects (state) 222 | { 223 | $group: { 224 | _id: '$votes.constituencies.constituency', 225 | yes: { $sum: '$votes.constituencies.yes' }, 226 | no: { $sum: '$votes.constituencies.no' }, 227 | abstain: { $sum: '$votes.constituencies.abstain' }, 228 | }, 229 | }, 230 | { 231 | $addFields: { 232 | total: { $add: ['$yes', '$no', '$abstain'] }, 233 | }, 234 | }, 235 | // Build correct result 236 | { 237 | $project: { 238 | _id: false, 239 | constituency: '$_id', 240 | yes: '$yes', 241 | no: '$no', 242 | abstination: '$abstain', 243 | }, 244 | }, 245 | ]) 246 | // TODO Change query to make the filter obsolet (preserveNullAndEmptyArrays) 247 | // Remove elements with property constituency: null (of no votes on it) 248 | .then((data) => data.filter(({ constituency }) => constituency)) 249 | : []; 250 | 251 | if (votesGlobal.length > 0) { 252 | votesGlobal[0].constituencies = votesConstituencies; 253 | return votesGlobal[0]; 254 | } 255 | return null; 256 | }, 257 | voteStatistic: async (parent, args, { user, ProcedureModel, VoteModel, phone, device }) => { 258 | global.Log.graphql('Vote.query.voteStatistic', user.isVerified()); 259 | if (!user.isVerified()) { 260 | return null; 261 | } 262 | 263 | const period = { $gte: CONFIG.MIN_PERIOD }; 264 | 265 | // This query should reference the ProcedureModel Method isCompleted 266 | // TODO is that possible? 267 | const proceduresCount = await ProcedureModel.find({ 268 | period, 269 | $or: [ 270 | { voteDate: { $type: 'date' } }, 271 | { currentStatus: { $in: PROCEDURE_STATES.COMPLETED } }, 272 | { 273 | currentStatus: { $in: [PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG] }, 274 | voteDate: { $not: { $type: 'date' } }, 275 | }, 276 | { 277 | currentStatus: { 278 | $in: [ 279 | PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG, 280 | PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN, 281 | ], 282 | }, 283 | voteDate: { $gte: new Date() }, 284 | }, 285 | ], 286 | }).count(); 287 | 288 | const votedProcedures = await VoteModel.find( 289 | { 290 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 291 | voters: { 292 | $elemMatch: { 293 | voter: CONFIG.SMS_VERIFICATION ? phone._id : device._id, 294 | }, 295 | }, 296 | }, 297 | { procedure: 1 }, 298 | ).count(); 299 | 300 | return { 301 | proceduresCount, 302 | votedProcedures, 303 | }; 304 | }, 305 | }, 306 | 307 | Mutation: { 308 | vote: async ( 309 | parent, 310 | { procedure: procedureId, selection, constituency }, 311 | { VoteModel, ProcedureModel, ActivityModel, DeviceModel, device, phone, ...restContext }, 312 | info, 313 | ) => { 314 | global.Log.graphql('Vote.mutation.vote'); 315 | // Find procedure 316 | const procedure = Types.ObjectId.isValid(procedureId) 317 | ? await ProcedureModel.findById(procedureId) 318 | : await ProcedureModel.findOne({ procedureId }); 319 | // Fail if not existant or not votable 320 | if (!procedure) { 321 | throw new Error('Not votable'); 322 | } 323 | // User Has Voted? 324 | const hasVoted = await VoteModel.findOne({ 325 | procedure, 326 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 327 | voters: { 328 | $elemMatch: { 329 | voter: CONFIG.SMS_VERIFICATION ? phone._id : device._id, 330 | }, 331 | }, 332 | }); 333 | // Fail if user has already voted 334 | if (hasVoted) { 335 | global.Log.warn('User tried to vote twice - vote was not counted!'); 336 | throw new Error('You have already voted'); 337 | } 338 | // Decide Bucket to put user-vote in 339 | const state = procedure.isCompleted() ? 'COMPLETED' : 'VOTING'; 340 | // Find & Create Vote Model if needed 341 | let vote = await VoteModel.findOne({ 342 | procedure, 343 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 344 | state, 345 | }); 346 | if (!vote) { 347 | vote = await VoteModel.create({ 348 | procedure, 349 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 350 | state, 351 | }); 352 | } 353 | // Add constituency bucket object if needed 354 | if (constituency) { 355 | await VoteModel.findByIdAndUpdate(vote._id, { 356 | $addToSet: { 357 | 'votes.constituencies': { 358 | constituency, 359 | }, 360 | }, 361 | }); 362 | } 363 | 364 | // Cast Vote 365 | const voteUpdate: MongooseFilterQuery = { 366 | $push: { 367 | voters: { 368 | voter: CONFIG.SMS_VERIFICATION ? phone._id : device._id, 369 | }, 370 | }, 371 | }; 372 | // Cache needs to be controlled manually 373 | switch (selection) { 374 | case 'YES': 375 | if (constituency) { 376 | voteUpdate.$inc = { 'votes.constituencies.$.yes': 1, 'votes.cache.yes': 1 }; 377 | } else { 378 | voteUpdate.$inc = { 'votes.general.yes': 1, 'votes.cache.yes': 1 }; 379 | } 380 | break; 381 | case 'NO': 382 | if (constituency) { 383 | voteUpdate.$inc = { 'votes.constituencies.$.no': 1, 'votes.cache.no': 1 }; 384 | } else { 385 | voteUpdate.$inc = { 'votes.general.no': 1, 'votes.cache.no': 1 }; 386 | } 387 | break; 388 | case 'ABSTINATION': 389 | if (constituency) { 390 | voteUpdate.$inc = { 'votes.constituencies.$.abstain': 1, 'votes.cache.abstain': 1 }; 391 | } else { 392 | voteUpdate.$inc = { 'votes.general.abstain': 1, 'votes.cache.abstain': 1 }; 393 | } 394 | break; 395 | 396 | default: 397 | throw new Error(`Invlaid Vote Selection: ${selection}`); 398 | } 399 | 400 | // Write Vote 401 | await VoteModel.updateOne( 402 | { 403 | _id: vote._id, 404 | // Add the constituency bucket selector conditionally 405 | ...(constituency && { 'votes.constituencies.constituency': constituency }), 406 | }, 407 | { ...voteUpdate }, 408 | ); 409 | 410 | // Recalculate Votes Cache 411 | // TODO for performance we could also increase the counter by one instead 412 | const votes = await VoteModel.aggregate([ 413 | // Find Procedure 414 | { 415 | $match: { 416 | procedure: procedure._id, 417 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 418 | }, 419 | }, 420 | // Sum both objects (state) 421 | { 422 | $group: { 423 | _id: '$procedure', 424 | yes: { $sum: '$votes.cache.yes' }, 425 | no: { $sum: '$votes.cache.no' }, 426 | abstination: { $sum: '$votes.cache.abstain' }, 427 | }, 428 | }, 429 | { 430 | $addFields: { 431 | total: { $add: ['$yes', '$no', '$abstination'] }, 432 | }, 433 | }, 434 | ]); 435 | if (votes.length) { 436 | await ProcedureModel.findByIdAndUpdate(procedure._id, { 437 | votes: votes[0].total, 438 | 'voteResults.communityVotes': { 439 | yes: votes[0].yes, 440 | no: votes[0].no, 441 | abstination: votes[0].abstination, 442 | }, 443 | }); 444 | } 445 | 446 | // Increate Activity 447 | if ( 448 | ActivityApi.Mutation && 449 | ActivityApi.Mutation.increaseActivity && 450 | typeof ActivityApi.Mutation.increaseActivity === 'function' 451 | ) { 452 | await ActivityApi.Mutation.increaseActivity( 453 | parent, 454 | { procedureId }, 455 | { 456 | ProcedureModel, 457 | ActivityModel, 458 | VoteModel, 459 | DeviceModel, 460 | phone, 461 | device, 462 | ...restContext, 463 | }, 464 | info, 465 | ); 466 | } 467 | 468 | // Autosubscribe to Pushs for this Procedure & User if the user has the outcomePushs-Setting enabled 469 | if (device.notificationSettings.outcomePushs) { 470 | await DeviceModel.updateOne( 471 | { _id: device._id }, 472 | { $addToSet: { 'notificationSettings.procedures': procedure._id } }, 473 | ); 474 | } 475 | 476 | // Return new User Vote Results 477 | return queryVotes( 478 | parent, 479 | { procedure: procedure._id, constituencies: constituency ? [constituency] : null }, 480 | { 481 | VoteModel, 482 | device, 483 | phone, 484 | ...restContext, 485 | }, 486 | ); 487 | }, 488 | }, 489 | VoteResult: { 490 | governmentDecision: (parent) => { 491 | const { yes, no } = parent; 492 | if (typeof yes === 'number' && typeof no === 'number') { 493 | return yes > no ? VoteSelection.Yes : VoteSelection.No; 494 | } 495 | }, 496 | 497 | deputyVotes: async (voteResult, { constituencies, directCandidate }, { DeputyModel }) => { 498 | global.Log.graphql('VoteResult.deputyVotes'); 499 | // Do we have a procedureId and not an empty array for constituencies? 500 | if ( 501 | voteResult.procedureId && 502 | ((constituencies && constituencies.length > 0) || constituencies === undefined) 503 | ) { 504 | // Match procedureId 505 | const match: MongooseFilterQuery = { 506 | $match: { 507 | 'votes.procedureId': voteResult.procedureId, 508 | }, 509 | }; 510 | // Match constituencies if present - else use all 511 | if (constituencies) { 512 | match.$match = { ...match.$match, constituency: { $in: constituencies } }; 513 | } 514 | // Match for directCandidate 515 | if (directCandidate) { 516 | match.$match = { ...match.$match, directCandidate }; 517 | } 518 | 519 | // Query 520 | const deputies = await DeputyModel.aggregate([match]); 521 | 522 | // Construct result 523 | return deputies.reduce< 524 | { 525 | decision: VoteSelection; 526 | deputy: IDeputy; 527 | }[] 528 | >((pre, deputy) => { 529 | const pDeputyVote = deputy.votes.find( 530 | ({ procedureId }) => procedureId === voteResult.procedureId, 531 | ); 532 | if (pDeputyVote) { 533 | const deputyVote = { 534 | decision: pDeputyVote.decision, 535 | deputy: deputy, 536 | }; 537 | return [...pre, deputyVote]; 538 | } 539 | return pre; 540 | }, []); 541 | } 542 | return []; 543 | }, 544 | }, 545 | }; 546 | export default VoteApi; 547 | -------------------------------------------------------------------------------- /src/graphql/resolvers/Procedure.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-underscore-dangle: ["error", { "allow": ["_id"] }] */ 2 | import _ from 'lodash'; 3 | 4 | import { PROCEDURE as PROCEDURE_DEFINITIONS } from '@democracy-deutschland/bundestag.io-definitions'; 5 | import { IProcedure } from '@democracy-deutschland/democracy-common'; 6 | import { MongooseFilterQuery } from 'mongoose'; 7 | import { parseResolveInfo } from 'graphql-parse-resolve-info'; 8 | import PROCEDURE_STATES from '../../config/procedureStates'; 9 | import CONFIG from '../../config'; 10 | 11 | import elasticsearch from '../../services/search'; 12 | 13 | import { Resolvers, ListType, ProcedureType } from '../../generated/graphql'; 14 | 15 | const ProcedureApi: Resolvers = { 16 | Query: { 17 | proceduresWithVoteResults: async (parent, { procedureIds }, { ProcedureModel }) => { 18 | const procedures = ProcedureModel.find({ 19 | procedureId: { $in: procedureIds }, 20 | 'voteResults.yes': { $ne: null }, 21 | 'voteResults.no': { $ne: null }, 22 | 'voteResults.abstination': { $ne: null }, 23 | }); 24 | return procedures; 25 | }, 26 | procedures: async ( 27 | parent, 28 | { 29 | listTypes: listTypeParam = [], 30 | type, 31 | offset = 0, 32 | pageSize = 100, 33 | sort = 'lastUpdateDate', 34 | filter = {}, 35 | }, 36 | { ProcedureModel, user, VoteModel, device, phone }, 37 | ) => { 38 | // global.Log.graphql('Procedure.query.procedures'); 39 | let listTypes = listTypeParam as ListType[]; 40 | if (type) { 41 | switch (type) { 42 | case ProcedureType.InVote: 43 | case ProcedureType.Voting: 44 | listTypes = [ListType.InVote, ListType.Past]; 45 | break; 46 | case ProcedureType.Preparation: 47 | listTypes = [ListType.Preparation]; 48 | break; 49 | case ProcedureType.Past: 50 | listTypes = [ListType.Past]; 51 | break; 52 | case ProcedureType.Hot: 53 | listTypes = [ListType.Hot]; 54 | break; 55 | default: 56 | listTypes = [ListType[type]]; 57 | break; 58 | } 59 | } 60 | 61 | const filterQuery: MongooseFilterQuery = {}; 62 | if (filter && filter.type && filter.type.length > 0) { 63 | filterQuery.type = { $in: filter.type as string[] }; 64 | } 65 | if (filter && filter.subjectGroups && filter.subjectGroups.length > 0) { 66 | filterQuery.subjectGroups = { $in: filter.subjectGroups as string[] }; 67 | } 68 | if (filter && filter.activity && filter.activity.length > 0 && user && user.isVerified()) { 69 | const votedProcedures = await VoteModel.find( 70 | { 71 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 72 | voters: { 73 | $elemMatch: { 74 | voter: CONFIG.SMS_VERIFICATION ? phone : device, 75 | }, 76 | }, 77 | }, 78 | { procedure: 1 }, 79 | ).populate('procedure'); 80 | 81 | if (filter.activity.indexOf('notVoted') !== -1) { 82 | if (Array.isArray(votedProcedures)) { 83 | filterQuery._id = { 84 | $nin: votedProcedures.map(({ procedure }) => 85 | '_id' in procedure ? procedure._id : procedure, 86 | ), 87 | }; 88 | } 89 | } else if (filter.activity.indexOf('voted') !== -1) { 90 | filterQuery._id = { 91 | $in: votedProcedures.map(({ procedure }) => 92 | '_id' in procedure ? procedure._id : procedure, 93 | ), 94 | }; 95 | } 96 | } 97 | 98 | let sortQuery = {}; 99 | 100 | const period = { $gte: CONFIG.MIN_PERIOD }; 101 | 102 | if (listTypes.indexOf(ListType.Preparation) > -1) { 103 | switch (sort) { 104 | case 'activities': 105 | sortQuery = { activities: -1, lastUpdateDate: -1, title: 1 }; 106 | break; 107 | case 'created': 108 | sortQuery = { submissionDate: -1, lastUpdateDate: -1, title: 1 }; 109 | break; 110 | 111 | default: 112 | sortQuery = { 113 | lastUpdateDate: -1, 114 | title: 1, 115 | }; 116 | break; 117 | } 118 | return ProcedureModel.find({ 119 | currentStatus: { $in: PROCEDURE_STATES.PREPARATION }, 120 | period, 121 | voteDate: { $not: { $type: 'date' } }, 122 | ...filterQuery, 123 | }) 124 | .sort(sortQuery) 125 | .limit(pageSize as number) 126 | .skip(offset as number); 127 | } 128 | 129 | if (listTypes.indexOf(ListType.Hot) > -1) { 130 | const oneWeekAgo = new Date(); 131 | const hotProcedures = await ProcedureModel.find({ 132 | period, 133 | activities: { $gt: 0 }, 134 | $or: [ 135 | { voteDate: { $gt: new Date(oneWeekAgo.setDate(oneWeekAgo.getDate() - 7)) } }, 136 | { voteDate: { $not: { $type: 'date' } } }, 137 | ], 138 | ...filterQuery, 139 | }) 140 | .sort({ activities: -1, lastUpdateDate: -1, title: 1 }) 141 | .skip(offset as number) 142 | .limit(pageSize as number); 143 | 144 | return hotProcedures; 145 | } 146 | 147 | if (listTypes.indexOf(ListType.Top100) !== -1) { 148 | const top100Procedures = await ProcedureModel.find({ 149 | period, 150 | ...filterQuery, 151 | }) 152 | .sort({ votes: -1 }) 153 | .skip(offset as number) 154 | .limit(pageSize as number); 155 | 156 | return top100Procedures; 157 | } 158 | 159 | if (listTypes.indexOf(ListType.ConferenceweeksPlanned) !== -1) { 160 | const top100Procedures = await ProcedureModel.find({ 161 | period, 162 | $or: [ 163 | { 164 | $and: [ 165 | { voteDate: { $gte: new Date() } }, 166 | { $or: [{ voteEnd: { $exists: false } }, { voteEnd: undefined }] }, 167 | ], 168 | }, 169 | { voteEnd: { $gte: new Date() } }, 170 | ], 171 | ...filterQuery, 172 | }) 173 | .sort({ voteDate: 1, voteEnd: 1, votes: -1 }) 174 | .skip(offset as number) 175 | .limit(pageSize as number); 176 | 177 | return top100Procedures; 178 | } 179 | 180 | switch (sort) { 181 | case 'activities': 182 | sortQuery = { activities: -1, lastUpdateDate: -1, title: 1 }; 183 | break; 184 | 185 | default: 186 | sortQuery = { 187 | nlt: 1, 188 | voteDate: -1, 189 | lastUpdateDate: -1, 190 | title: 1, 191 | }; 192 | break; 193 | } 194 | 195 | let activeVotings = []; 196 | if (listTypes.indexOf(ListType.InVote) > -1) { 197 | activeVotings = await ProcedureModel.aggregate([ 198 | { 199 | $match: { 200 | $or: [ 201 | { 202 | currentStatus: { $in: [PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG] }, 203 | voteDate: { $not: { $type: 'date' } }, 204 | }, 205 | { 206 | currentStatus: { 207 | $in: [ 208 | PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG, 209 | PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN, 210 | ], 211 | }, 212 | voteDate: { $gte: new Date() }, 213 | }, 214 | ], 215 | period, 216 | ...filterQuery, 217 | }, 218 | }, 219 | { 220 | $addFields: { 221 | nlt: { $ifNull: ['$voteDate', new Date('9000-01-01')] }, 222 | }, 223 | }, 224 | { $sort: sortQuery }, 225 | { $skip: offset }, 226 | { $limit: pageSize }, 227 | ]); 228 | } 229 | 230 | let pastVotings: IProcedure[] = []; 231 | if (listTypes.indexOf(ListType.Past) > -1) { 232 | if (activeVotings.length < (pageSize as number)) { 233 | const activeVotingsCount = 234 | listTypes.indexOf(ListType.InVote) > -1 235 | ? await ProcedureModel.find({ 236 | $or: [ 237 | { 238 | currentStatus: { $in: [PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG] }, 239 | voteDate: { $not: { $type: 'date' } }, 240 | }, 241 | { 242 | currentStatus: { 243 | $in: [ 244 | PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG, 245 | PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN, 246 | ], 247 | }, 248 | voteDate: { $gte: new Date() }, 249 | }, 250 | ], 251 | period, 252 | ...filterQuery, 253 | }).count() 254 | : 0; 255 | 256 | pastVotings = await ProcedureModel.find({ 257 | $or: [ 258 | { voteDate: { $lt: new Date() } }, 259 | { currentStatus: { $in: PROCEDURE_STATES.COMPLETED } }, 260 | ], 261 | period, 262 | ...filterQuery, 263 | }) 264 | .sort(sortQuery) 265 | .skip(Math.max((offset as number) - activeVotingsCount, 0)) 266 | .limit((pageSize as number) - activeVotings.length); 267 | } 268 | } 269 | 270 | return [...activeVotings, ...pastVotings]; 271 | }, 272 | 273 | votedProcedures: async (parent, args, { VoteModel, phone, device }) => { 274 | // global.Log.graphql('Procedure.query.votedProcedures'); 275 | 276 | const actor = CONFIG.SMS_VERIFICATION ? phone._id : device._id; 277 | const kind = CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device'; 278 | const votedProcedures = await VoteModel.aggregate<{ 279 | procedure: IProcedure & { active: boolean; voted: boolean }; 280 | }>([ 281 | { 282 | $match: { 283 | type: kind, 284 | voters: { 285 | $elemMatch: { 286 | voter: actor, 287 | }, 288 | }, 289 | }, 290 | }, 291 | { 292 | $lookup: { 293 | from: 'procedures', 294 | localField: 'procedure', 295 | foreignField: '_id', 296 | as: 'procedure', 297 | }, 298 | }, 299 | { $unwind: '$procedure' }, 300 | { 301 | $lookup: { 302 | from: 'activities', 303 | let: { procedure: '$procedure._id' }, 304 | pipeline: [ 305 | { 306 | $match: { 307 | $expr: { 308 | $and: [ 309 | { $eq: ['$procedure', '$$procedure'] }, 310 | { $eq: ['$actor', actor] }, 311 | { $eq: ['$kind', kind] }, 312 | ], 313 | }, 314 | }, 315 | }, 316 | ], 317 | as: 'activitiesLookup', 318 | }, 319 | }, 320 | { 321 | $addFields: { 322 | 'procedure.active': { $gt: [{ $size: '$activitiesLookup' }, 0] }, 323 | 'procedure.voted': true, 324 | }, 325 | }, 326 | ]); 327 | 328 | const procedures = votedProcedures.map(({ procedure }) => procedure); 329 | 330 | return procedures; 331 | }, 332 | 333 | proceduresById: async (parent, { ids }, { ProcedureModel }) => { 334 | // global.Log.graphql('Procedure.query.proceduresById'); 335 | return ProcedureModel.find({ _id: { $in: ids } }); 336 | }, 337 | 338 | proceduresByIdHavingVoteResults: async ( 339 | parent, 340 | { procedureIds, timespan = 'Period', pageSize = 25, offset = 0, filter = {} }, 341 | { ProcedureModel }, 342 | ) => { 343 | // Vote Results are present Filter 344 | const voteResultsQuery: MongooseFilterQuery = { 345 | 'voteResults.yes': { $ne: null }, 346 | 'voteResults.no': { $ne: null }, 347 | 'voteResults.abstination': { $ne: null }, 348 | 'voteResults.partyVotes': { $gt: [] }, 349 | }; 350 | 351 | // Timespan Selection 352 | const timespanQuery: MongooseFilterQuery = {}; 353 | switch (timespan) { 354 | case 'CurrentSittingWeek': 355 | case 'LastSittingWeek': 356 | throw new Error('Not implemented/Not supported yet'); 357 | case 'CurrentQuarter': 358 | { 359 | const now = new Date(); 360 | const quarter = Math.floor(now.getMonth() / 3); 361 | const firstDate = new Date(now.getFullYear(), quarter * 3, 1); 362 | const endDate = new Date(firstDate.getFullYear(), firstDate.getMonth() + 3, 0); 363 | timespanQuery.voteDate = { 364 | $gte: firstDate, 365 | $lt: endDate, 366 | }; 367 | } 368 | break; 369 | case 'LastQuarter': 370 | { 371 | const now = new Date(); 372 | let year = now.getFullYear(); 373 | let quarter = Math.floor(now.getMonth() / 3) - 1; 374 | if (quarter === -1) { 375 | quarter = 3; 376 | year -= 1; 377 | } 378 | const firstDate = new Date(year, quarter * 3, 1); 379 | const endDate = new Date(firstDate.getFullYear(), firstDate.getMonth() + 3, 0); 380 | timespanQuery.voteDate = { 381 | $gte: firstDate, 382 | $lt: endDate, 383 | }; 384 | } 385 | break; 386 | case 'CurrentYear': 387 | timespanQuery.voteDate = { $gte: new Date(new Date().getFullYear(), 0, 1) }; 388 | break; 389 | case 'LastYear': 390 | timespanQuery.voteDate = { 391 | $gte: new Date(new Date().getFullYear() - 1, 0, 1), 392 | $lt: new Date(new Date().getFullYear(), 0, 1), 393 | }; 394 | break; 395 | case 'Period': 396 | timespanQuery.period = { $in: [CONFIG.MIN_PERIOD] }; 397 | break; 398 | default: 399 | } 400 | 401 | // WOM Filter 402 | const filterQuery: MongooseFilterQuery = {}; 403 | // WOM Filter Subject Group 404 | if (filter && filter.subjectGroups && filter.subjectGroups.length > 0) { 405 | filterQuery.subjectGroups = { $in: filter.subjectGroups as string[] }; 406 | } 407 | 408 | // Prepare Find Query 409 | const findQuery: MongooseFilterQuery = { 410 | // Vote Results are present 411 | ...voteResultsQuery, 412 | // Timespan Selection 413 | ...timespanQuery, 414 | // Apply Filter 415 | ...filterQuery, 416 | }; 417 | 418 | // Count total Procedures matching given Filter 419 | const total = await ProcedureModel.count(findQuery); 420 | 421 | // if empty, return all procedures having VoteResults 422 | if (procedureIds) { 423 | // Procedure ID selection 424 | findQuery.procedureId = { $in: procedureIds }; 425 | } 426 | 427 | // Find selected procedures matching given Filter 428 | const procedures = await ProcedureModel.find(findQuery) 429 | // Sorting last voted first 430 | .sort({ voteDate: -1 }) 431 | // Pagination 432 | .limit(pageSize as number) 433 | .skip(offset as number) 434 | .then((procedures) => { 435 | // Filter Andere(fraktionslos) from partyVotes array in result, rename party(CDU -> Union) 436 | return procedures.map((p) => { 437 | // MongoObject to JS Object 438 | const procedure: IProcedure = p.toObject(); 439 | // eslint-disable-next-line no-param-reassign 440 | if (procedure.voteResults) { 441 | procedure.voteResults.partyVotes = procedure.voteResults.partyVotes?.filter( 442 | ({ party }) => !['Andere', 'fraktionslos'].includes(party.trim()), 443 | ); 444 | 445 | // Rename Fractions 446 | procedure.voteResults.partyVotes = procedure.voteResults.partyVotes?.map( 447 | ({ party, ...rest }) => { 448 | switch (party.trim()) { 449 | case 'CDU': 450 | return { ...rest, party: 'Union' }; 451 | 452 | default: 453 | return { ...rest, party }; 454 | } 455 | }, 456 | ); 457 | } 458 | return procedure; 459 | }); 460 | }); 461 | 462 | // Return result 463 | return { total, procedures }; 464 | }, 465 | 466 | procedure: async (parent, { id }, { user, device, ProcedureModel }) => { 467 | // global.Log.graphql('Procedure.query.procedure'); 468 | const procedure = await ProcedureModel.findOne({ procedureId: id }); 469 | // TODO fail here of procedure is null 470 | if (!procedure) { 471 | return null; 472 | } 473 | 474 | return { 475 | ...procedure.toObject(), 476 | notify: !!(device && device.notificationSettings.procedures.indexOf(procedure._id) > -1), 477 | verified: user ? user.isVerified() : false, 478 | }; 479 | }, 480 | 481 | searchProceduresAutocomplete: async (parent, { term }, { ProcedureModel }) => { 482 | // global.Log.graphql('Procedure.query.searchProceduresAutocomplete'); 483 | const autocomplete: string[] = []; 484 | 485 | // Search by procedureID or Document id 486 | const directProcedures = await ProcedureModel.find({ 487 | $or: [ 488 | { procedureId: term }, 489 | { 490 | 'importantDocuments.number': term, 491 | }, 492 | ], 493 | }); 494 | if (directProcedures.length > 0) { 495 | return { 496 | procedures: directProcedures, 497 | autocomplete, 498 | }; 499 | } 500 | 501 | const mongoSearchProcedures = await ProcedureModel.find({ $text: { $search: term } }); 502 | if (mongoSearchProcedures.length > 0) { 503 | return { 504 | procedures: mongoSearchProcedures, 505 | autocomplete, 506 | }; 507 | } 508 | 509 | const { hits } = await elasticsearch.search<{ procedureId: string }>({ 510 | index: 'procedures', 511 | type: 'procedure', 512 | body: { 513 | query: { 514 | function_score: { 515 | query: { 516 | bool: { 517 | must: [ 518 | { 519 | term: { period: 19 }, 520 | }, 521 | { 522 | query_string: { 523 | query: "type:'Antrag' OR type:'Gesetzgebung'", 524 | }, 525 | }, 526 | { 527 | multi_match: { 528 | query: `*${term}*`, 529 | fields: ['title^3', 'tags^2.5', 'abstract^2'], 530 | fuzziness: 'AUTO', 531 | prefix_length: 3, 532 | }, 533 | }, 534 | ], 535 | }, 536 | }, 537 | }, 538 | }, 539 | 540 | suggest: { 541 | autocomplete: { 542 | text: `${term}`, 543 | term: { 544 | field: 'title', 545 | suggest_mode: 'popular', 546 | }, 547 | }, 548 | }, 549 | }, 550 | }); 551 | 552 | // prepare procedures 553 | const procedureIds = hits.hits.map(({ _source: { procedureId } }) => procedureId); 554 | const procedures = await ProcedureModel.find({ procedureId: { $in: procedureIds } }); 555 | 556 | // prepare autocomplete 557 | // if (suggest.autocomplete[0]) { 558 | // autocomplete = suggest.autocomplete[0].options.map(({ text }) => text); 559 | // } 560 | return { 561 | procedures: 562 | _.sortBy(procedures, ({ procedureId }) => procedureIds.indexOf(procedureId)) || [], 563 | autocomplete: [], 564 | }; 565 | }, 566 | 567 | // DEPRECATED 568 | searchProcedures: async (parent, { term }, { ProcedureModel }) => { 569 | // global.Log.graphql('Procedure.query.searchProcedures'); 570 | const { hits } = await elasticsearch.search<{ procedureId: string }>({ 571 | index: 'procedures', 572 | type: 'procedure', 573 | body: { 574 | query: { 575 | function_score: { 576 | query: { 577 | bool: { 578 | must: [ 579 | { 580 | term: { period: 19 }, 581 | }, 582 | { 583 | query_string: { 584 | query: "type:'Antrag' OR type:'Gesetzgebung'", 585 | }, 586 | }, 587 | { 588 | multi_match: { 589 | query: `*${term}*`, 590 | fields: ['title^3', 'tags^2.5', 'abstract^2'], 591 | fuzziness: 'AUTO', 592 | prefix_length: 3, 593 | }, 594 | }, 595 | ], 596 | }, 597 | }, 598 | }, 599 | }, 600 | }, 601 | }); 602 | 603 | // prepare procedures 604 | const procedureIds = hits.hits.map(({ _source: { procedureId } }) => procedureId); 605 | return ProcedureModel.find({ procedureId: { $in: procedureIds } }); 606 | }, 607 | 608 | notifiedProcedures: async (parent, args, { device, ProcedureModel }) => { 609 | // global.Log.graphql('Procedure.query.notifiedProcedures'); 610 | const procedures = await ProcedureModel.find({ 611 | _id: { $in: device.notificationSettings.procedures }, 612 | }); 613 | 614 | return procedures.map((procedure) => ({ 615 | ...procedure.toObject(), 616 | notify: true, 617 | })); 618 | }, 619 | }, 620 | 621 | Procedure: { 622 | communityVotes: async (procedure, { constituencies }, { VoteModel, ProcedureModel }, info) => { 623 | // global.Log.graphql('Procedure.query.communityVotes'); 624 | // Find global result(cache), not including constituencies 625 | 626 | const requestedFields = parseResolveInfo(info); 627 | let getConstituencyResults = true; 628 | if (requestedFields && requestedFields.fieldsByTypeName) { 629 | getConstituencyResults = 630 | 'constituencies' in requestedFields.fieldsByTypeName.CommunityVotes; 631 | } 632 | 633 | // Use cached community results 634 | if ( 635 | procedure.voteResults && 636 | procedure.voteResults.communityVotes && 637 | procedure.voteResults.communityVotes.yes && 638 | procedure.voteResults.communityVotes.no && 639 | procedure.voteResults.communityVotes.abstination && 640 | Number.isInteger(procedure.voteResults.communityVotes.yes) && 641 | Number.isInteger(procedure.voteResults.communityVotes.no) && 642 | Number.isInteger(procedure.voteResults.communityVotes.abstination) && 643 | !getConstituencyResults 644 | ) { 645 | return { 646 | ...procedure.voteResults.communityVotes, 647 | total: 648 | procedure.voteResults.communityVotes.yes + 649 | procedure.voteResults.communityVotes.no + 650 | procedure.voteResults.communityVotes.abstination, 651 | }; 652 | } 653 | 654 | const votesGlobal = await VoteModel.aggregate([ 655 | // Find Procedure 656 | { 657 | $match: { 658 | procedure: procedure._id, 659 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 660 | }, 661 | }, 662 | // Sum both objects (state) 663 | { 664 | $group: { 665 | _id: '$procedure', 666 | yes: { $sum: '$votes.cache.yes' }, 667 | no: { $sum: '$votes.cache.no' }, 668 | abstination: { $sum: '$votes.cache.abstain' }, 669 | }, 670 | }, 671 | { 672 | $addFields: { 673 | total: { $add: ['$yes', '$no', '$abstination'] }, 674 | }, 675 | }, 676 | // Remove _id from result 677 | { 678 | $project: { 679 | _id: false, 680 | }, 681 | }, 682 | ]); 683 | 684 | // Find constituency results if constituencies are given 685 | let votesConstituencies = undefined; 686 | if (getConstituencyResults) { 687 | votesConstituencies = 688 | (constituencies && constituencies.length > 0) || constituencies === undefined 689 | ? await VoteModel.aggregate<{ 690 | _id: false; 691 | constituency: string; 692 | yes: number; 693 | no: number; 694 | abstination: number; 695 | total: number; 696 | }>([ 697 | // Find Procedure, including type; results in up to two objects for state 698 | { 699 | $match: { 700 | procedure: procedure._id, 701 | type: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 702 | }, 703 | }, 704 | // Filter correct constituency 705 | { 706 | $project: { 707 | votes: { 708 | constituencies: { 709 | $filter: { 710 | input: '$votes.constituencies', 711 | as: 'constituency', 712 | cond: !constituencies 713 | ? true // Return all Constituencies if constituencies param is not given 714 | : { $in: ['$$constituency.constituency', constituencies] }, // Filter Constituencies if an array is given 715 | }, 716 | }, 717 | }, 718 | }, 719 | }, 720 | // Unwind constituencies for sum, but preserve null 721 | { 722 | $unwind: { 723 | path: '$votes.constituencies', 724 | preserveNullAndEmptyArrays: true, 725 | }, 726 | }, 727 | // Sum both objects (state) 728 | { 729 | $group: { 730 | _id: '$votes.constituencies.constituency', 731 | yes: { $sum: '$votes.constituencies.yes' }, 732 | no: { $sum: '$votes.constituencies.no' }, 733 | abstain: { $sum: '$votes.constituencies.abstain' }, 734 | }, 735 | }, 736 | { 737 | $addFields: { 738 | total: { $add: ['$yes', '$no', '$abstain'] }, 739 | }, 740 | }, 741 | // Build correct result 742 | { 743 | $project: { 744 | _id: false, 745 | constituency: '$_id', 746 | yes: '$yes', 747 | no: '$no', 748 | abstination: '$abstain', 749 | total: '$total', 750 | }, 751 | }, 752 | ]) 753 | // TODO Change query to make the filter obsolet (preserveNullAndEmptyArrays) 754 | // Remove elements with property constituency: null (of no votes on it) 755 | .then((data) => data.filter(({ constituency }) => constituency)) 756 | : []; 757 | } else { 758 | // do cache community results for next requests 759 | if (votesGlobal.length > 0) { 760 | await ProcedureModel.update( 761 | { _id: procedure._id }, 762 | { 763 | $set: { 764 | 'voteResults.communityVotes': { 765 | yes: votesGlobal[0].yes, 766 | no: votesGlobal[0].no, 767 | abstination: votesGlobal[0].abstination, 768 | }, 769 | }, 770 | }, 771 | ); 772 | } 773 | } 774 | if (votesGlobal.length > 0) { 775 | votesGlobal[0].constituencies = votesConstituencies; 776 | return votesGlobal[0]; 777 | } 778 | return null; 779 | }, 780 | activityIndex: async (procedure, args, { ActivityModel, phone, device }) => { 781 | return { 782 | activityIndex: procedure.votes || 0, 783 | active: false, 784 | }; 785 | // // deprecated 786 | // const activityIndex = procedure.activities || 0; 787 | // let { active } = procedure; 788 | // if (active === undefined) { 789 | // active = 790 | // (CONFIG.SMS_VERIFICATION && !phone) || (!CONFIG.SMS_VERIFICATION && !device) 791 | // ? false 792 | // : !!(await ActivityModel.findOne({ 793 | // actor: CONFIG.SMS_VERIFICATION ? phone._id : device._id, 794 | // kind: CONFIG.SMS_VERIFICATION ? 'Phone' : 'Device', 795 | // procedure: procedure._id, 796 | // })); 797 | // } 798 | // return { 799 | // activityIndex, 800 | // active, 801 | // }; 802 | }, 803 | voted: async ({ voted, _id }, args, { votedLoader }) => { 804 | if (voted === undefined) { 805 | return votedLoader.load(_id); 806 | } 807 | return !!voted; 808 | }, 809 | votedGovernment: (procedure) => { 810 | // global.Log.graphql('Procedure.field.votedGovernment'); 811 | return !!( 812 | procedure.voteResults && 813 | (procedure.voteResults.yes || procedure.voteResults.abstination || procedure.voteResults.no) 814 | ); 815 | }, 816 | completed: (procedure) => { 817 | // global.Log.graphql('Procedure.field.completed'); 818 | return PROCEDURE_STATES.COMPLETED.includes(procedure.currentStatus || ''); 819 | }, 820 | // DEPRECATED ListType 2019-01-29 use list instead 821 | listType: (procedure) => { 822 | // global.Log.graphql('Procedure.field.listType'); 823 | if ( 824 | procedure.currentStatus === PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG || 825 | (procedure.currentStatus === PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN && 826 | procedure.voteDate && 827 | new Date(procedure.voteDate) >= new Date()) || 828 | PROCEDURE_STATES.COMPLETED.some((s) => s === procedure.currentStatus || procedure.voteDate) 829 | ) { 830 | return ProcedureType.InVote; 831 | } 832 | return ProcedureType.Preparation; 833 | }, 834 | list: (procedure) => { 835 | // global.Log.graphql('Procedure.field.list'); 836 | if (procedure.voteDate && new Date(procedure.voteDate) < new Date()) { 837 | return ListType.Past; 838 | } 839 | if ( 840 | procedure.currentStatus === PROCEDURE_DEFINITIONS.STATUS.BESCHLUSSEMPFEHLUNG || 841 | (procedure.currentStatus === PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN && 842 | procedure.voteDate && 843 | new Date(procedure.voteDate) >= new Date()) || 844 | PROCEDURE_STATES.COMPLETED.some((s) => s === procedure.currentStatus || procedure.voteDate) 845 | ) { 846 | return ListType.InVote; 847 | } 848 | return ListType.Preparation; 849 | }, 850 | currentStatusHistory: ({ currentStatusHistory }) => { 851 | // global.Log.graphql('Procedure.field.currentStatusHistory'); 852 | const cleanHistory = [...new Set(currentStatusHistory)]; 853 | const referStatusIndex = cleanHistory.findIndex( 854 | (status) => status === PROCEDURE_DEFINITIONS.STATUS.UEBERWIESEN, 855 | ); 856 | if (referStatusIndex !== -1) { 857 | cleanHistory.splice(referStatusIndex, 0, '1. Beratung'); 858 | } 859 | 860 | const resultStaties = [ 861 | PROCEDURE_DEFINITIONS.STATUS.ANGENOMMEN, 862 | PROCEDURE_DEFINITIONS.STATUS.ABGELEHNT, 863 | PROCEDURE_DEFINITIONS.STATUS.ABBESCHLOSSEN_VORGANGSABLAUF, 864 | PROCEDURE_DEFINITIONS.STATUS.ABGESCHLOSSEN, 865 | PROCEDURE_DEFINITIONS.STATUS.VERKUENDET, 866 | PROCEDURE_DEFINITIONS.STATUS.VERABSCHIEDET, 867 | PROCEDURE_DEFINITIONS.STATUS.BR_ZUGESTIMMT, 868 | PROCEDURE_DEFINITIONS.STATUS.BR_EINSPRUCH, 869 | PROCEDURE_DEFINITIONS.STATUS.BR_ZUSTIMMUNG_VERSAGT, 870 | PROCEDURE_DEFINITIONS.STATUS.BR_VERMITTLUNGSAUSSCHUSS_NICHT_ANGERUFEN, 871 | PROCEDURE_DEFINITIONS.STATUS.VERMITTLUNGSVERFAHREN, 872 | PROCEDURE_DEFINITIONS.STATUS.VERMITTLUNGSVORSCHLAG, 873 | PROCEDURE_DEFINITIONS.STATUS.UNVEREINBAR_MIT_GRUNDGESETZ, 874 | PROCEDURE_DEFINITIONS.STATUS.BP_ZUSTIMMUNGSVERWEIGERUNG, 875 | PROCEDURE_DEFINITIONS.STATUS.ZUSTIMMUNG_VERSAGT, 876 | ]; 877 | const resultStatusIndex = cleanHistory.findIndex((status) => resultStaties.includes(status)); 878 | if (resultStatusIndex !== -1) { 879 | cleanHistory.splice(resultStatusIndex, 0, '2. Beratung / 3. Beratung'); 880 | } 881 | return cleanHistory; 882 | }, 883 | // Propagate procedureId if present 884 | voteResults: ({ voteResults, procedureId }) => { 885 | // global.Log.graphql('Procedure.field.voteResults'); 886 | if ( 887 | voteResults && 888 | typeof voteResults.yes === 'number' && 889 | typeof voteResults.no === 'number' 890 | ) { 891 | return { ...voteResults, procedureId }; 892 | } 893 | return null; 894 | }, 895 | votes: ({ votes }) => { 896 | return votes || 0; 897 | }, 898 | }, 899 | }; 900 | 901 | export default ProcedureApi; 902 | -------------------------------------------------------------------------------- /src/generated/graphql.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo, GraphQLScalarType, GraphQLScalarTypeConfig } from 'graphql'; 2 | import { IProcedure, IDeputy } from '@democracy-deutschland/democracy-common'; 3 | import { GraphQlContext } from '../types/graphqlContext'; 4 | import { DeepPartial } from 'utility-types'; 5 | export type Maybe = T | null; 6 | export type Omit = Pick>; 7 | export type RequireFields = { [X in Exclude]?: T[X] } & 8 | { [P in K]-?: NonNullable }; 9 | /** All built-in and custom scalars, mapped to their actual values */ 10 | export type Scalars = { 11 | ID: string; 12 | String: string; 13 | Boolean: boolean; 14 | Int: number; 15 | Float: number; 16 | Date: any; 17 | }; 18 | 19 | export type ActivityIndex = { 20 | __typename?: 'ActivityIndex'; 21 | activityIndex: Scalars['Int']; 22 | active?: Maybe; 23 | }; 24 | 25 | export type AdditionalEntityFields = { 26 | path?: Maybe; 27 | type?: Maybe; 28 | }; 29 | 30 | export type Auth = { 31 | __typename?: 'Auth'; 32 | token: Scalars['String']; 33 | }; 34 | 35 | export type CodeResult = { 36 | __typename?: 'CodeResult'; 37 | reason?: Maybe; 38 | allowNewUser?: Maybe; 39 | succeeded: Scalars['Boolean']; 40 | resendTime?: Maybe; 41 | expireTime?: Maybe; 42 | }; 43 | 44 | export type CommunityConstituencyVotes = { 45 | __typename?: 'CommunityConstituencyVotes'; 46 | constituency: Scalars['String']; 47 | yes: Scalars['Int']; 48 | no: Scalars['Int']; 49 | abstination: Scalars['Int']; 50 | total: Scalars['Int']; 51 | }; 52 | 53 | export type CommunityVotes = { 54 | __typename?: 'CommunityVotes'; 55 | yes: Scalars['Int']; 56 | no: Scalars['Int']; 57 | abstination: Scalars['Int']; 58 | total: Scalars['Int']; 59 | constituencies: Array; 60 | }; 61 | 62 | export type ConferenceWeek = { 63 | __typename?: 'ConferenceWeek'; 64 | start: Scalars['Date']; 65 | end: Scalars['Date']; 66 | calendarWeek: Scalars['Int']; 67 | }; 68 | 69 | export type Deputy = { 70 | __typename?: 'Deputy'; 71 | _id: Scalars['ID']; 72 | webId: Scalars['String']; 73 | imgURL: Scalars['String']; 74 | name: Scalars['String']; 75 | party?: Maybe; 76 | job?: Maybe; 77 | biography?: Maybe; 78 | constituency?: Maybe; 79 | directCandidate?: Maybe; 80 | contact?: Maybe; 81 | totalProcedures?: Maybe; 82 | procedures: Array; 83 | }; 84 | 85 | export type DeputyProceduresArgs = { 86 | procedureIds?: Maybe>; 87 | pageSize?: Maybe; 88 | offset?: Maybe; 89 | }; 90 | 91 | export type DeputyContact = { 92 | __typename?: 'DeputyContact'; 93 | address?: Maybe; 94 | email?: Maybe; 95 | links: Array; 96 | }; 97 | 98 | export type DeputyLink = { 99 | __typename?: 'DeputyLink'; 100 | name: Scalars['String']; 101 | URL: Scalars['String']; 102 | }; 103 | 104 | export type DeputyProcedure = { 105 | __typename?: 'DeputyProcedure'; 106 | decision: VoteSelection; 107 | procedure: Procedure; 108 | }; 109 | 110 | export type DeputyVote = { 111 | __typename?: 'DeputyVote'; 112 | deputy: Deputy; 113 | decision: VoteSelection; 114 | }; 115 | 116 | export type Deviants = { 117 | __typename?: 'Deviants'; 118 | yes: Scalars['Int']; 119 | abstination: Scalars['Int']; 120 | no: Scalars['Int']; 121 | notVoted?: Maybe; 122 | }; 123 | 124 | export type Device = { 125 | __typename?: 'Device'; 126 | notificationSettings?: Maybe; 127 | }; 128 | 129 | export type Document = { 130 | __typename?: 'Document'; 131 | editor: Scalars['String']; 132 | number: Scalars['String']; 133 | type: Scalars['String']; 134 | url: Scalars['String']; 135 | }; 136 | 137 | export enum ListType { 138 | Preparation = 'PREPARATION', 139 | InVote = 'IN_VOTE', 140 | Past = 'PAST', 141 | Hot = 'HOT', 142 | Top100 = 'TOP100', 143 | ConferenceweeksPlanned = 'CONFERENCEWEEKS_PLANNED', 144 | } 145 | 146 | export type Mutation = { 147 | __typename?: 'Mutation'; 148 | increaseActivity?: Maybe; 149 | requestCode: CodeResult; 150 | requestVerification: VerificationResult; 151 | addToken: TokenResult; 152 | updateNotificationSettings?: Maybe; 153 | toggleNotification?: Maybe; 154 | finishSearch: SearchTerm; 155 | signUp?: Maybe; 156 | vote: Vote; 157 | }; 158 | 159 | export type MutationIncreaseActivityArgs = { 160 | procedureId: Scalars['String']; 161 | }; 162 | 163 | export type MutationRequestCodeArgs = { 164 | newPhone: Scalars['String']; 165 | oldPhoneHash?: Maybe; 166 | }; 167 | 168 | export type MutationRequestVerificationArgs = { 169 | code: Scalars['String']; 170 | newPhoneHash: Scalars['String']; 171 | newUser?: Maybe; 172 | }; 173 | 174 | export type MutationAddTokenArgs = { 175 | token: Scalars['String']; 176 | os: Scalars['String']; 177 | }; 178 | 179 | export type MutationUpdateNotificationSettingsArgs = { 180 | enabled?: Maybe; 181 | newVote?: Maybe; 182 | newPreperation?: Maybe; 183 | conferenceWeekPushs?: Maybe; 184 | voteConferenceWeekPushs?: Maybe; 185 | voteTOP100Pushs?: Maybe; 186 | outcomePushs?: Maybe; 187 | outcomePushsEnableOld?: Maybe; 188 | disableUntil?: Maybe; 189 | procedures?: Maybe>>; 190 | tags?: Maybe>>; 191 | }; 192 | 193 | export type MutationToggleNotificationArgs = { 194 | procedureId: Scalars['String']; 195 | }; 196 | 197 | export type MutationFinishSearchArgs = { 198 | term: Scalars['String']; 199 | }; 200 | 201 | export type MutationSignUpArgs = { 202 | deviceHashEncrypted: Scalars['String']; 203 | }; 204 | 205 | export type MutationVoteArgs = { 206 | procedure: Scalars['ID']; 207 | selection: VoteSelection; 208 | constituency?: Maybe; 209 | }; 210 | 211 | export type NotificationSettings = { 212 | __typename?: 'NotificationSettings'; 213 | enabled?: Maybe; 214 | /** @deprecated <= 1.22 Notification Settings */ 215 | newVote?: Maybe; 216 | /** @deprecated <= 1.22 Notification Settings */ 217 | newPreperation?: Maybe; 218 | conferenceWeekPushs?: Maybe; 219 | voteConferenceWeekPushs?: Maybe; 220 | voteTOP100Pushs?: Maybe; 221 | outcomePushs?: Maybe; 222 | disableUntil?: Maybe; 223 | procedures?: Maybe>>; 224 | tags?: Maybe>>; 225 | }; 226 | 227 | export type PartyVote = { 228 | __typename?: 'PartyVote'; 229 | party: Scalars['String']; 230 | main: VoteSelection; 231 | deviants: Deviants; 232 | }; 233 | 234 | export type Procedure = { 235 | __typename?: 'Procedure'; 236 | _id: Scalars['ID']; 237 | title: Scalars['String']; 238 | procedureId: Scalars['String']; 239 | type: Scalars['String']; 240 | period?: Maybe; 241 | currentStatus?: Maybe; 242 | currentStatusHistory: Array; 243 | abstract?: Maybe; 244 | tags: Array; 245 | voteDate?: Maybe; 246 | voteEnd?: Maybe; 247 | voteWeek?: Maybe; 248 | voteYear?: Maybe; 249 | sessionTOPHeading?: Maybe; 250 | subjectGroups: Array; 251 | submissionDate?: Maybe; 252 | activityIndex: ActivityIndex; 253 | votes: Scalars['Int']; 254 | importantDocuments: Array; 255 | voteResults?: Maybe; 256 | communityVotes?: Maybe; 257 | voted: Scalars['Boolean']; 258 | votedGovernment?: Maybe; 259 | completed?: Maybe; 260 | notify?: Maybe; 261 | /** @deprecated Use listTypes instead of type */ 262 | listType?: Maybe; 263 | list?: Maybe; 264 | verified?: Maybe; 265 | }; 266 | 267 | export type ProcedureCommunityVotesArgs = { 268 | constituencies?: Maybe>; 269 | }; 270 | 271 | export type ProcedureFilter = { 272 | subjectGroups?: Maybe>; 273 | status?: Maybe>; 274 | type?: Maybe>; 275 | activity?: Maybe>; 276 | }; 277 | 278 | export type ProceduresHavingVoteResults = { 279 | __typename?: 'ProceduresHavingVoteResults'; 280 | total: Scalars['Int']; 281 | procedures: Array; 282 | }; 283 | 284 | export enum ProcedureType { 285 | InVote = 'IN_VOTE', 286 | Preparation = 'PREPARATION', 287 | Voting = 'VOTING', 288 | Past = 'PAST', 289 | Hot = 'HOT', 290 | } 291 | 292 | export type ProcedureWomFilter = { 293 | subjectGroups: Array; 294 | }; 295 | 296 | export type Query = { 297 | __typename?: 'Query'; 298 | activityIndex?: Maybe; 299 | currentConferenceWeek: ConferenceWeek; 300 | deputiesOfConstituency: Array; 301 | notificationSettings?: Maybe; 302 | procedure: Procedure; 303 | procedures: Array; 304 | proceduresById: Array; 305 | proceduresByIdHavingVoteResults: ProceduresHavingVoteResults; 306 | notifiedProcedures: Array; 307 | /** @deprecated use searchProceduresAutocomplete */ 308 | searchProcedures: Array; 309 | searchProceduresAutocomplete: SearchProcedures; 310 | votedProcedures: Array; 311 | proceduresWithVoteResults: Array; 312 | mostSearched: Array; 313 | me?: Maybe; 314 | votes?: Maybe; 315 | communityVotes?: Maybe; 316 | voteStatistic?: Maybe; 317 | }; 318 | 319 | export type QueryActivityIndexArgs = { 320 | procedureId: Scalars['String']; 321 | }; 322 | 323 | export type QueryDeputiesOfConstituencyArgs = { 324 | constituency: Scalars['String']; 325 | directCandidate?: Maybe; 326 | }; 327 | 328 | export type QueryProcedureArgs = { 329 | id: Scalars['ID']; 330 | }; 331 | 332 | export type QueryProceduresArgs = { 333 | listTypes?: Maybe>; 334 | type?: Maybe; 335 | pageSize?: Maybe; 336 | offset?: Maybe; 337 | sort?: Maybe; 338 | filter?: Maybe; 339 | }; 340 | 341 | export type QueryProceduresByIdArgs = { 342 | ids: Array; 343 | pageSize?: Maybe; 344 | offset?: Maybe; 345 | }; 346 | 347 | export type QueryProceduresByIdHavingVoteResultsArgs = { 348 | procedureIds?: Maybe>; 349 | timespan?: Maybe; 350 | pageSize?: Maybe; 351 | offset?: Maybe; 352 | filter?: Maybe; 353 | }; 354 | 355 | export type QuerySearchProceduresArgs = { 356 | term: Scalars['String']; 357 | }; 358 | 359 | export type QuerySearchProceduresAutocompleteArgs = { 360 | term: Scalars['String']; 361 | }; 362 | 363 | export type QueryProceduresWithVoteResultsArgs = { 364 | procedureIds: Array; 365 | }; 366 | 367 | export type QueryVotesArgs = { 368 | procedure: Scalars['ID']; 369 | constituencies?: Maybe>; 370 | }; 371 | 372 | export type QueryCommunityVotesArgs = { 373 | procedure: Scalars['ID']; 374 | constituencies?: Maybe>; 375 | }; 376 | 377 | export type Schema = { 378 | __typename?: 'Schema'; 379 | query?: Maybe; 380 | }; 381 | 382 | export type SearchProcedures = { 383 | __typename?: 'SearchProcedures'; 384 | procedures: Array; 385 | autocomplete: Array; 386 | }; 387 | 388 | export type SearchTerm = { 389 | __typename?: 'SearchTerm'; 390 | term: Scalars['String']; 391 | }; 392 | 393 | export type TokenResult = { 394 | __typename?: 'TokenResult'; 395 | succeeded?: Maybe; 396 | }; 397 | 398 | export type User = { 399 | __typename?: 'User'; 400 | _id: Scalars['String']; 401 | verified: Scalars['Boolean']; 402 | /** @deprecated Field no longer supported */ 403 | deviceHash?: Maybe; 404 | }; 405 | 406 | export type VerificationResult = { 407 | __typename?: 'VerificationResult'; 408 | reason?: Maybe; 409 | succeeded: Scalars['Boolean']; 410 | }; 411 | 412 | export type Vote = { 413 | __typename?: 'Vote'; 414 | _id: Scalars['ID']; 415 | voted: Scalars['Boolean']; 416 | voteResults?: Maybe; 417 | }; 418 | 419 | export enum VotedTimeSpan { 420 | CurrentSittingWeek = 'CurrentSittingWeek', 421 | LastSittingWeek = 'LastSittingWeek', 422 | CurrentQuarter = 'CurrentQuarter', 423 | LastQuarter = 'LastQuarter', 424 | CurrentYear = 'CurrentYear', 425 | LastYear = 'LastYear', 426 | Period = 'Period', 427 | } 428 | 429 | export type VoteResult = { 430 | __typename?: 'VoteResult'; 431 | procedureId: Scalars['String']; 432 | yes: Scalars['Int']; 433 | no: Scalars['Int']; 434 | abstination: Scalars['Int']; 435 | notVoted?: Maybe; 436 | /** @deprecated Field no longer supported */ 437 | notVote?: Maybe; 438 | governmentDecision: VoteSelection; 439 | decisionText?: Maybe; 440 | namedVote: Scalars['Boolean']; 441 | partyVotes: Array; 442 | deputyVotes: Array; 443 | }; 444 | 445 | export type VoteResultDeputyVotesArgs = { 446 | constituencies?: Maybe>; 447 | directCandidate?: Maybe; 448 | }; 449 | 450 | export enum VoteSelection { 451 | Yes = 'YES', 452 | No = 'NO', 453 | Abstination = 'ABSTINATION', 454 | Notvoted = 'NOTVOTED', 455 | } 456 | 457 | export type VoteStatistic = { 458 | __typename?: 'VoteStatistic'; 459 | proceduresCount: Scalars['Int']; 460 | votedProcedures: Scalars['Int']; 461 | }; 462 | 463 | export type ResolverTypeWrapper = Promise | T; 464 | 465 | export type StitchingResolver = { 466 | fragment: string; 467 | resolve: ResolverFn; 468 | }; 469 | 470 | export type Resolver = 471 | | ResolverFn 472 | | StitchingResolver; 473 | 474 | export type ResolverFn = ( 475 | parent: TParent, 476 | args: TArgs, 477 | context: TContext, 478 | info: GraphQLResolveInfo, 479 | ) => Promise | TResult; 480 | 481 | export type SubscriptionSubscribeFn = ( 482 | parent: TParent, 483 | args: TArgs, 484 | context: TContext, 485 | info: GraphQLResolveInfo, 486 | ) => AsyncIterator | Promise>; 487 | 488 | export type SubscriptionResolveFn = ( 489 | parent: TParent, 490 | args: TArgs, 491 | context: TContext, 492 | info: GraphQLResolveInfo, 493 | ) => TResult | Promise; 494 | 495 | export interface SubscriptionSubscriberObject< 496 | TResult, 497 | TKey extends string, 498 | TParent, 499 | TContext, 500 | TArgs 501 | > { 502 | subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs>; 503 | resolve?: SubscriptionResolveFn; 504 | } 505 | 506 | export interface SubscriptionResolverObject { 507 | subscribe: SubscriptionSubscribeFn; 508 | resolve: SubscriptionResolveFn; 509 | } 510 | 511 | export type SubscriptionObject = 512 | | SubscriptionSubscriberObject 513 | | SubscriptionResolverObject; 514 | 515 | export type SubscriptionResolver< 516 | TResult, 517 | TKey extends string, 518 | TParent = {}, 519 | TContext = {}, 520 | TArgs = {} 521 | > = 522 | | ((...args: any[]) => SubscriptionObject) 523 | | SubscriptionObject; 524 | 525 | export type TypeResolveFn = ( 526 | parent: TParent, 527 | context: TContext, 528 | info: GraphQLResolveInfo, 529 | ) => Maybe | Promise>; 530 | 531 | export type isTypeOfResolverFn = ( 532 | obj: T, 533 | info: GraphQLResolveInfo, 534 | ) => boolean | Promise; 535 | 536 | export type NextResolverFn = () => Promise; 537 | 538 | export type DirectiveResolverFn = ( 539 | next: NextResolverFn, 540 | parent: TParent, 541 | args: TArgs, 542 | context: TContext, 543 | info: GraphQLResolveInfo, 544 | ) => TResult | Promise; 545 | 546 | /** Mapping between all available schema types and the resolvers types */ 547 | export type ResolversTypes = { 548 | Query: ResolverTypeWrapper<{}>; 549 | String: ResolverTypeWrapper>; 550 | ActivityIndex: ResolverTypeWrapper>; 551 | Int: ResolverTypeWrapper>; 552 | Boolean: ResolverTypeWrapper>; 553 | ConferenceWeek: ResolverTypeWrapper>; 554 | Date: ResolverTypeWrapper>; 555 | Deputy: ResolverTypeWrapper; 556 | ID: ResolverTypeWrapper>; 557 | DeputyContact: ResolverTypeWrapper>; 558 | DeputyLink: ResolverTypeWrapper>; 559 | DeputyProcedure: ResolverTypeWrapper< 560 | DeepPartial & { procedure: ResolversTypes['Procedure'] }> 561 | >; 562 | VoteSelection: ResolverTypeWrapper>; 563 | Procedure: ResolverTypeWrapper; 564 | Document: ResolverTypeWrapper>; 565 | VoteResult: ResolverTypeWrapper< 566 | DeepPartial< 567 | Omit & { deputyVotes: Array } 568 | > 569 | >; 570 | PartyVote: ResolverTypeWrapper>; 571 | Deviants: ResolverTypeWrapper>; 572 | DeputyVote: ResolverTypeWrapper< 573 | DeepPartial & { deputy: ResolversTypes['Deputy'] }> 574 | >; 575 | CommunityVotes: ResolverTypeWrapper>; 576 | CommunityConstituencyVotes: ResolverTypeWrapper>; 577 | ProcedureType: ResolverTypeWrapper>; 578 | ListType: ResolverTypeWrapper>; 579 | NotificationSettings: ResolverTypeWrapper>; 580 | ProcedureFilter: ResolverTypeWrapper>; 581 | VotedTimeSpan: ResolverTypeWrapper>; 582 | ProcedureWOMFilter: ResolverTypeWrapper>; 583 | ProceduresHavingVoteResults: ResolverTypeWrapper< 584 | DeepPartial< 585 | Omit & { 586 | procedures: Array; 587 | } 588 | > 589 | >; 590 | SearchProcedures: ResolverTypeWrapper< 591 | DeepPartial< 592 | Omit & { procedures: Array } 593 | > 594 | >; 595 | SearchTerm: ResolverTypeWrapper>; 596 | User: ResolverTypeWrapper>; 597 | Vote: ResolverTypeWrapper>; 598 | VoteStatistic: ResolverTypeWrapper>; 599 | Mutation: ResolverTypeWrapper<{}>; 600 | CodeResult: ResolverTypeWrapper>; 601 | VerificationResult: ResolverTypeWrapper>; 602 | TokenResult: ResolverTypeWrapper>; 603 | Auth: ResolverTypeWrapper>; 604 | Device: ResolverTypeWrapper>; 605 | Schema: ResolverTypeWrapper< 606 | DeepPartial & { query?: Maybe }> 607 | >; 608 | AdditionalEntityFields: ResolverTypeWrapper>; 609 | }; 610 | 611 | /** Mapping between all available schema types and the resolvers parents */ 612 | export type ResolversParentTypes = { 613 | Query: {}; 614 | String: DeepPartial; 615 | ActivityIndex: DeepPartial; 616 | Int: DeepPartial; 617 | Boolean: DeepPartial; 618 | ConferenceWeek: DeepPartial; 619 | Date: DeepPartial; 620 | Deputy: IDeputy; 621 | ID: DeepPartial; 622 | DeputyContact: DeepPartial; 623 | DeputyLink: DeepPartial; 624 | DeputyProcedure: DeepPartial< 625 | Omit & { procedure: ResolversParentTypes['Procedure'] } 626 | >; 627 | VoteSelection: DeepPartial; 628 | Procedure: IProcedure; 629 | Document: DeepPartial; 630 | VoteResult: DeepPartial< 631 | Omit & { deputyVotes: Array } 632 | >; 633 | PartyVote: DeepPartial; 634 | Deviants: DeepPartial; 635 | DeputyVote: DeepPartial & { deputy: ResolversParentTypes['Deputy'] }>; 636 | CommunityVotes: DeepPartial; 637 | CommunityConstituencyVotes: DeepPartial; 638 | ProcedureType: DeepPartial; 639 | ListType: DeepPartial; 640 | NotificationSettings: DeepPartial; 641 | ProcedureFilter: DeepPartial; 642 | VotedTimeSpan: DeepPartial; 643 | ProcedureWOMFilter: DeepPartial; 644 | ProceduresHavingVoteResults: DeepPartial< 645 | Omit & { 646 | procedures: Array; 647 | } 648 | >; 649 | SearchProcedures: DeepPartial< 650 | Omit & { procedures: Array } 651 | >; 652 | SearchTerm: DeepPartial; 653 | User: DeepPartial; 654 | Vote: DeepPartial; 655 | VoteStatistic: DeepPartial; 656 | Mutation: {}; 657 | CodeResult: DeepPartial; 658 | VerificationResult: DeepPartial; 659 | TokenResult: DeepPartial; 660 | Auth: DeepPartial; 661 | Device: DeepPartial; 662 | Schema: DeepPartial & { query?: Maybe }>; 663 | AdditionalEntityFields: DeepPartial; 664 | }; 665 | 666 | export type UnionDirectiveArgs = { 667 | discriminatorField?: Maybe; 668 | additionalFields?: Maybe>>; 669 | }; 670 | 671 | export type UnionDirectiveResolver< 672 | Result, 673 | Parent, 674 | ContextType = GraphQlContext, 675 | Args = UnionDirectiveArgs 676 | > = DirectiveResolverFn; 677 | 678 | export type AbstractEntityDirectiveArgs = { 679 | discriminatorField: Scalars['String']; 680 | additionalFields?: Maybe>>; 681 | }; 682 | 683 | export type AbstractEntityDirectiveResolver< 684 | Result, 685 | Parent, 686 | ContextType = GraphQlContext, 687 | Args = AbstractEntityDirectiveArgs 688 | > = DirectiveResolverFn; 689 | 690 | export type EntityDirectiveArgs = { 691 | embedded?: Maybe; 692 | additionalFields?: Maybe>>; 693 | }; 694 | 695 | export type EntityDirectiveResolver< 696 | Result, 697 | Parent, 698 | ContextType = GraphQlContext, 699 | Args = EntityDirectiveArgs 700 | > = DirectiveResolverFn; 701 | 702 | export type ColumnDirectiveArgs = { overrideType?: Maybe }; 703 | 704 | export type ColumnDirectiveResolver< 705 | Result, 706 | Parent, 707 | ContextType = GraphQlContext, 708 | Args = ColumnDirectiveArgs 709 | > = DirectiveResolverFn; 710 | 711 | export type IdDirectiveArgs = {}; 712 | 713 | export type IdDirectiveResolver< 714 | Result, 715 | Parent, 716 | ContextType = GraphQlContext, 717 | Args = IdDirectiveArgs 718 | > = DirectiveResolverFn; 719 | 720 | export type LinkDirectiveArgs = { overrideType?: Maybe }; 721 | 722 | export type LinkDirectiveResolver< 723 | Result, 724 | Parent, 725 | ContextType = GraphQlContext, 726 | Args = LinkDirectiveArgs 727 | > = DirectiveResolverFn; 728 | 729 | export type EmbeddedDirectiveArgs = {}; 730 | 731 | export type EmbeddedDirectiveResolver< 732 | Result, 733 | Parent, 734 | ContextType = GraphQlContext, 735 | Args = EmbeddedDirectiveArgs 736 | > = DirectiveResolverFn; 737 | 738 | export type MapDirectiveArgs = { path: Scalars['String'] }; 739 | 740 | export type MapDirectiveResolver< 741 | Result, 742 | Parent, 743 | ContextType = GraphQlContext, 744 | Args = MapDirectiveArgs 745 | > = DirectiveResolverFn; 746 | 747 | export type ActivityIndexResolvers< 748 | ContextType = GraphQlContext, 749 | ParentType extends ResolversParentTypes['ActivityIndex'] = ResolversParentTypes['ActivityIndex'] 750 | > = { 751 | activityIndex?: Resolver; 752 | active?: Resolver, ParentType, ContextType>; 753 | __isTypeOf?: isTypeOfResolverFn; 754 | }; 755 | 756 | export type AuthResolvers< 757 | ContextType = GraphQlContext, 758 | ParentType extends ResolversParentTypes['Auth'] = ResolversParentTypes['Auth'] 759 | > = { 760 | token?: Resolver; 761 | __isTypeOf?: isTypeOfResolverFn; 762 | }; 763 | 764 | export type CodeResultResolvers< 765 | ContextType = GraphQlContext, 766 | ParentType extends ResolversParentTypes['CodeResult'] = ResolversParentTypes['CodeResult'] 767 | > = { 768 | reason?: Resolver, ParentType, ContextType>; 769 | allowNewUser?: Resolver, ParentType, ContextType>; 770 | succeeded?: Resolver; 771 | resendTime?: Resolver, ParentType, ContextType>; 772 | expireTime?: Resolver, ParentType, ContextType>; 773 | __isTypeOf?: isTypeOfResolverFn; 774 | }; 775 | 776 | export type CommunityConstituencyVotesResolvers< 777 | ContextType = GraphQlContext, 778 | ParentType extends ResolversParentTypes['CommunityConstituencyVotes'] = ResolversParentTypes['CommunityConstituencyVotes'] 779 | > = { 780 | constituency?: Resolver; 781 | yes?: Resolver; 782 | no?: Resolver; 783 | abstination?: Resolver; 784 | total?: Resolver; 785 | __isTypeOf?: isTypeOfResolverFn; 786 | }; 787 | 788 | export type CommunityVotesResolvers< 789 | ContextType = GraphQlContext, 790 | ParentType extends ResolversParentTypes['CommunityVotes'] = ResolversParentTypes['CommunityVotes'] 791 | > = { 792 | yes?: Resolver; 793 | no?: Resolver; 794 | abstination?: Resolver; 795 | total?: Resolver; 796 | constituencies?: Resolver< 797 | Array, 798 | ParentType, 799 | ContextType 800 | >; 801 | __isTypeOf?: isTypeOfResolverFn; 802 | }; 803 | 804 | export type ConferenceWeekResolvers< 805 | ContextType = GraphQlContext, 806 | ParentType extends ResolversParentTypes['ConferenceWeek'] = ResolversParentTypes['ConferenceWeek'] 807 | > = { 808 | start?: Resolver; 809 | end?: Resolver; 810 | calendarWeek?: Resolver; 811 | __isTypeOf?: isTypeOfResolverFn; 812 | }; 813 | 814 | export interface DateScalarConfig extends GraphQLScalarTypeConfig { 815 | name: 'Date'; 816 | } 817 | 818 | export type DeputyResolvers< 819 | ContextType = GraphQlContext, 820 | ParentType extends ResolversParentTypes['Deputy'] = ResolversParentTypes['Deputy'] 821 | > = { 822 | _id?: Resolver; 823 | webId?: Resolver; 824 | imgURL?: Resolver; 825 | name?: Resolver; 826 | party?: Resolver, ParentType, ContextType>; 827 | job?: Resolver, ParentType, ContextType>; 828 | biography?: Resolver, ParentType, ContextType>; 829 | constituency?: Resolver, ParentType, ContextType>; 830 | directCandidate?: Resolver, ParentType, ContextType>; 831 | contact?: Resolver, ParentType, ContextType>; 832 | totalProcedures?: Resolver, ParentType, ContextType>; 833 | procedures?: Resolver< 834 | Array, 835 | ParentType, 836 | ContextType, 837 | RequireFields 838 | >; 839 | __isTypeOf?: isTypeOfResolverFn; 840 | }; 841 | 842 | export type DeputyContactResolvers< 843 | ContextType = GraphQlContext, 844 | ParentType extends ResolversParentTypes['DeputyContact'] = ResolversParentTypes['DeputyContact'] 845 | > = { 846 | address?: Resolver, ParentType, ContextType>; 847 | email?: Resolver, ParentType, ContextType>; 848 | links?: Resolver, ParentType, ContextType>; 849 | __isTypeOf?: isTypeOfResolverFn; 850 | }; 851 | 852 | export type DeputyLinkResolvers< 853 | ContextType = GraphQlContext, 854 | ParentType extends ResolversParentTypes['DeputyLink'] = ResolversParentTypes['DeputyLink'] 855 | > = { 856 | name?: Resolver; 857 | URL?: Resolver; 858 | __isTypeOf?: isTypeOfResolverFn; 859 | }; 860 | 861 | export type DeputyProcedureResolvers< 862 | ContextType = GraphQlContext, 863 | ParentType extends ResolversParentTypes['DeputyProcedure'] = ResolversParentTypes['DeputyProcedure'] 864 | > = { 865 | decision?: Resolver; 866 | procedure?: Resolver; 867 | __isTypeOf?: isTypeOfResolverFn; 868 | }; 869 | 870 | export type DeputyVoteResolvers< 871 | ContextType = GraphQlContext, 872 | ParentType extends ResolversParentTypes['DeputyVote'] = ResolversParentTypes['DeputyVote'] 873 | > = { 874 | deputy?: Resolver; 875 | decision?: Resolver; 876 | __isTypeOf?: isTypeOfResolverFn; 877 | }; 878 | 879 | export type DeviantsResolvers< 880 | ContextType = GraphQlContext, 881 | ParentType extends ResolversParentTypes['Deviants'] = ResolversParentTypes['Deviants'] 882 | > = { 883 | yes?: Resolver; 884 | abstination?: Resolver; 885 | no?: Resolver; 886 | notVoted?: Resolver, ParentType, ContextType>; 887 | __isTypeOf?: isTypeOfResolverFn; 888 | }; 889 | 890 | export type DeviceResolvers< 891 | ContextType = GraphQlContext, 892 | ParentType extends ResolversParentTypes['Device'] = ResolversParentTypes['Device'] 893 | > = { 894 | notificationSettings?: Resolver< 895 | Maybe, 896 | ParentType, 897 | ContextType 898 | >; 899 | __isTypeOf?: isTypeOfResolverFn; 900 | }; 901 | 902 | export type DocumentResolvers< 903 | ContextType = GraphQlContext, 904 | ParentType extends ResolversParentTypes['Document'] = ResolversParentTypes['Document'] 905 | > = { 906 | editor?: Resolver; 907 | number?: Resolver; 908 | type?: Resolver; 909 | url?: Resolver; 910 | __isTypeOf?: isTypeOfResolverFn; 911 | }; 912 | 913 | export type MutationResolvers< 914 | ContextType = GraphQlContext, 915 | ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation'] 916 | > = { 917 | increaseActivity?: Resolver< 918 | Maybe, 919 | ParentType, 920 | ContextType, 921 | RequireFields 922 | >; 923 | requestCode?: Resolver< 924 | ResolversTypes['CodeResult'], 925 | ParentType, 926 | ContextType, 927 | RequireFields 928 | >; 929 | requestVerification?: Resolver< 930 | ResolversTypes['VerificationResult'], 931 | ParentType, 932 | ContextType, 933 | RequireFields 934 | >; 935 | addToken?: Resolver< 936 | ResolversTypes['TokenResult'], 937 | ParentType, 938 | ContextType, 939 | RequireFields 940 | >; 941 | updateNotificationSettings?: Resolver< 942 | Maybe, 943 | ParentType, 944 | ContextType, 945 | RequireFields 946 | >; 947 | toggleNotification?: Resolver< 948 | Maybe, 949 | ParentType, 950 | ContextType, 951 | RequireFields 952 | >; 953 | finishSearch?: Resolver< 954 | ResolversTypes['SearchTerm'], 955 | ParentType, 956 | ContextType, 957 | RequireFields 958 | >; 959 | signUp?: Resolver< 960 | Maybe, 961 | ParentType, 962 | ContextType, 963 | RequireFields 964 | >; 965 | vote?: Resolver< 966 | ResolversTypes['Vote'], 967 | ParentType, 968 | ContextType, 969 | RequireFields 970 | >; 971 | }; 972 | 973 | export type NotificationSettingsResolvers< 974 | ContextType = GraphQlContext, 975 | ParentType extends ResolversParentTypes['NotificationSettings'] = ResolversParentTypes['NotificationSettings'] 976 | > = { 977 | enabled?: Resolver, ParentType, ContextType>; 978 | newVote?: Resolver, ParentType, ContextType>; 979 | newPreperation?: Resolver, ParentType, ContextType>; 980 | conferenceWeekPushs?: Resolver, ParentType, ContextType>; 981 | voteConferenceWeekPushs?: Resolver, ParentType, ContextType>; 982 | voteTOP100Pushs?: Resolver, ParentType, ContextType>; 983 | outcomePushs?: Resolver, ParentType, ContextType>; 984 | disableUntil?: Resolver, ParentType, ContextType>; 985 | procedures?: Resolver>>, ParentType, ContextType>; 986 | tags?: Resolver>>, ParentType, ContextType>; 987 | __isTypeOf?: isTypeOfResolverFn; 988 | }; 989 | 990 | export type PartyVoteResolvers< 991 | ContextType = GraphQlContext, 992 | ParentType extends ResolversParentTypes['PartyVote'] = ResolversParentTypes['PartyVote'] 993 | > = { 994 | party?: Resolver; 995 | main?: Resolver; 996 | deviants?: Resolver; 997 | __isTypeOf?: isTypeOfResolverFn; 998 | }; 999 | 1000 | export type ProcedureResolvers< 1001 | ContextType = GraphQlContext, 1002 | ParentType extends ResolversParentTypes['Procedure'] = ResolversParentTypes['Procedure'] 1003 | > = { 1004 | _id?: Resolver; 1005 | title?: Resolver; 1006 | procedureId?: Resolver; 1007 | type?: Resolver; 1008 | period?: Resolver, ParentType, ContextType>; 1009 | currentStatus?: Resolver, ParentType, ContextType>; 1010 | currentStatusHistory?: Resolver, ParentType, ContextType>; 1011 | abstract?: Resolver, ParentType, ContextType>; 1012 | tags?: Resolver, ParentType, ContextType>; 1013 | voteDate?: Resolver, ParentType, ContextType>; 1014 | voteEnd?: Resolver, ParentType, ContextType>; 1015 | voteWeek?: Resolver, ParentType, ContextType>; 1016 | voteYear?: Resolver, ParentType, ContextType>; 1017 | sessionTOPHeading?: Resolver, ParentType, ContextType>; 1018 | subjectGroups?: Resolver, ParentType, ContextType>; 1019 | submissionDate?: Resolver, ParentType, ContextType>; 1020 | activityIndex?: Resolver; 1021 | votes?: Resolver; 1022 | importantDocuments?: Resolver, ParentType, ContextType>; 1023 | voteResults?: Resolver, ParentType, ContextType>; 1024 | communityVotes?: Resolver< 1025 | Maybe, 1026 | ParentType, 1027 | ContextType, 1028 | RequireFields 1029 | >; 1030 | voted?: Resolver; 1031 | votedGovernment?: Resolver, ParentType, ContextType>; 1032 | completed?: Resolver, ParentType, ContextType>; 1033 | notify?: Resolver, ParentType, ContextType>; 1034 | listType?: Resolver, ParentType, ContextType>; 1035 | list?: Resolver, ParentType, ContextType>; 1036 | verified?: Resolver, ParentType, ContextType>; 1037 | __isTypeOf?: isTypeOfResolverFn; 1038 | }; 1039 | 1040 | export type ProceduresHavingVoteResultsResolvers< 1041 | ContextType = GraphQlContext, 1042 | ParentType extends ResolversParentTypes['ProceduresHavingVoteResults'] = ResolversParentTypes['ProceduresHavingVoteResults'] 1043 | > = { 1044 | total?: Resolver; 1045 | procedures?: Resolver, ParentType, ContextType>; 1046 | __isTypeOf?: isTypeOfResolverFn; 1047 | }; 1048 | 1049 | export type QueryResolvers< 1050 | ContextType = GraphQlContext, 1051 | ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query'] 1052 | > = { 1053 | activityIndex?: Resolver< 1054 | Maybe, 1055 | ParentType, 1056 | ContextType, 1057 | RequireFields 1058 | >; 1059 | currentConferenceWeek?: Resolver; 1060 | deputiesOfConstituency?: Resolver< 1061 | Array, 1062 | ParentType, 1063 | ContextType, 1064 | RequireFields 1065 | >; 1066 | notificationSettings?: Resolver< 1067 | Maybe, 1068 | ParentType, 1069 | ContextType 1070 | >; 1071 | procedure?: Resolver< 1072 | ResolversTypes['Procedure'], 1073 | ParentType, 1074 | ContextType, 1075 | RequireFields 1076 | >; 1077 | procedures?: Resolver< 1078 | Array, 1079 | ParentType, 1080 | ContextType, 1081 | RequireFields 1082 | >; 1083 | proceduresById?: Resolver< 1084 | Array, 1085 | ParentType, 1086 | ContextType, 1087 | RequireFields 1088 | >; 1089 | proceduresByIdHavingVoteResults?: Resolver< 1090 | ResolversTypes['ProceduresHavingVoteResults'], 1091 | ParentType, 1092 | ContextType, 1093 | RequireFields 1094 | >; 1095 | notifiedProcedures?: Resolver, ParentType, ContextType>; 1096 | searchProcedures?: Resolver< 1097 | Array, 1098 | ParentType, 1099 | ContextType, 1100 | RequireFields 1101 | >; 1102 | searchProceduresAutocomplete?: Resolver< 1103 | ResolversTypes['SearchProcedures'], 1104 | ParentType, 1105 | ContextType, 1106 | RequireFields 1107 | >; 1108 | votedProcedures?: Resolver, ParentType, ContextType>; 1109 | proceduresWithVoteResults?: Resolver< 1110 | Array, 1111 | ParentType, 1112 | ContextType, 1113 | RequireFields 1114 | >; 1115 | mostSearched?: Resolver, ParentType, ContextType>; 1116 | me?: Resolver, ParentType, ContextType>; 1117 | votes?: Resolver< 1118 | Maybe, 1119 | ParentType, 1120 | ContextType, 1121 | RequireFields 1122 | >; 1123 | communityVotes?: Resolver< 1124 | Maybe, 1125 | ParentType, 1126 | ContextType, 1127 | RequireFields 1128 | >; 1129 | voteStatistic?: Resolver, ParentType, ContextType>; 1130 | }; 1131 | 1132 | export type SchemaResolvers< 1133 | ContextType = GraphQlContext, 1134 | ParentType extends ResolversParentTypes['Schema'] = ResolversParentTypes['Schema'] 1135 | > = { 1136 | query?: Resolver, ParentType, ContextType>; 1137 | __isTypeOf?: isTypeOfResolverFn; 1138 | }; 1139 | 1140 | export type SearchProceduresResolvers< 1141 | ContextType = GraphQlContext, 1142 | ParentType extends ResolversParentTypes['SearchProcedures'] = ResolversParentTypes['SearchProcedures'] 1143 | > = { 1144 | procedures?: Resolver, ParentType, ContextType>; 1145 | autocomplete?: Resolver, ParentType, ContextType>; 1146 | __isTypeOf?: isTypeOfResolverFn; 1147 | }; 1148 | 1149 | export type SearchTermResolvers< 1150 | ContextType = GraphQlContext, 1151 | ParentType extends ResolversParentTypes['SearchTerm'] = ResolversParentTypes['SearchTerm'] 1152 | > = { 1153 | term?: Resolver; 1154 | __isTypeOf?: isTypeOfResolverFn; 1155 | }; 1156 | 1157 | export type TokenResultResolvers< 1158 | ContextType = GraphQlContext, 1159 | ParentType extends ResolversParentTypes['TokenResult'] = ResolversParentTypes['TokenResult'] 1160 | > = { 1161 | succeeded?: Resolver, ParentType, ContextType>; 1162 | __isTypeOf?: isTypeOfResolverFn; 1163 | }; 1164 | 1165 | export type UserResolvers< 1166 | ContextType = GraphQlContext, 1167 | ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User'] 1168 | > = { 1169 | _id?: Resolver; 1170 | verified?: Resolver; 1171 | deviceHash?: Resolver, ParentType, ContextType>; 1172 | __isTypeOf?: isTypeOfResolverFn; 1173 | }; 1174 | 1175 | export type VerificationResultResolvers< 1176 | ContextType = GraphQlContext, 1177 | ParentType extends ResolversParentTypes['VerificationResult'] = ResolversParentTypes['VerificationResult'] 1178 | > = { 1179 | reason?: Resolver, ParentType, ContextType>; 1180 | succeeded?: Resolver; 1181 | __isTypeOf?: isTypeOfResolverFn; 1182 | }; 1183 | 1184 | export type VoteResolvers< 1185 | ContextType = GraphQlContext, 1186 | ParentType extends ResolversParentTypes['Vote'] = ResolversParentTypes['Vote'] 1187 | > = { 1188 | _id?: Resolver; 1189 | voted?: Resolver; 1190 | voteResults?: Resolver, ParentType, ContextType>; 1191 | __isTypeOf?: isTypeOfResolverFn; 1192 | }; 1193 | 1194 | export type VoteResultResolvers< 1195 | ContextType = GraphQlContext, 1196 | ParentType extends ResolversParentTypes['VoteResult'] = ResolversParentTypes['VoteResult'] 1197 | > = { 1198 | procedureId?: Resolver; 1199 | yes?: Resolver; 1200 | no?: Resolver; 1201 | abstination?: Resolver; 1202 | notVoted?: Resolver, ParentType, ContextType>; 1203 | notVote?: Resolver, ParentType, ContextType>; 1204 | governmentDecision?: Resolver; 1205 | decisionText?: Resolver, ParentType, ContextType>; 1206 | namedVote?: Resolver; 1207 | partyVotes?: Resolver, ParentType, ContextType>; 1208 | deputyVotes?: Resolver< 1209 | Array, 1210 | ParentType, 1211 | ContextType, 1212 | RequireFields 1213 | >; 1214 | __isTypeOf?: isTypeOfResolverFn; 1215 | }; 1216 | 1217 | export type VoteStatisticResolvers< 1218 | ContextType = GraphQlContext, 1219 | ParentType extends ResolversParentTypes['VoteStatistic'] = ResolversParentTypes['VoteStatistic'] 1220 | > = { 1221 | proceduresCount?: Resolver; 1222 | votedProcedures?: Resolver; 1223 | __isTypeOf?: isTypeOfResolverFn; 1224 | }; 1225 | 1226 | export type Resolvers = { 1227 | ActivityIndex?: ActivityIndexResolvers; 1228 | Auth?: AuthResolvers; 1229 | CodeResult?: CodeResultResolvers; 1230 | CommunityConstituencyVotes?: CommunityConstituencyVotesResolvers; 1231 | CommunityVotes?: CommunityVotesResolvers; 1232 | ConferenceWeek?: ConferenceWeekResolvers; 1233 | Date?: GraphQLScalarType; 1234 | Deputy?: DeputyResolvers; 1235 | DeputyContact?: DeputyContactResolvers; 1236 | DeputyLink?: DeputyLinkResolvers; 1237 | DeputyProcedure?: DeputyProcedureResolvers; 1238 | DeputyVote?: DeputyVoteResolvers; 1239 | Deviants?: DeviantsResolvers; 1240 | Device?: DeviceResolvers; 1241 | Document?: DocumentResolvers; 1242 | Mutation?: MutationResolvers; 1243 | NotificationSettings?: NotificationSettingsResolvers; 1244 | PartyVote?: PartyVoteResolvers; 1245 | Procedure?: ProcedureResolvers; 1246 | ProceduresHavingVoteResults?: ProceduresHavingVoteResultsResolvers; 1247 | Query?: QueryResolvers; 1248 | Schema?: SchemaResolvers; 1249 | SearchProcedures?: SearchProceduresResolvers; 1250 | SearchTerm?: SearchTermResolvers; 1251 | TokenResult?: TokenResultResolvers; 1252 | User?: UserResolvers; 1253 | VerificationResult?: VerificationResultResolvers; 1254 | Vote?: VoteResolvers; 1255 | VoteResult?: VoteResultResolvers; 1256 | VoteStatistic?: VoteStatisticResolvers; 1257 | }; 1258 | 1259 | /** 1260 | * @deprecated 1261 | * Use "Resolvers" root object instead. If you wish to get "IResolvers", add "typesPrefix: I" to your config. 1262 | */ 1263 | export type IResolvers = Resolvers; 1264 | export type DirectiveResolvers = { 1265 | union?: UnionDirectiveResolver; 1266 | abstractEntity?: AbstractEntityDirectiveResolver; 1267 | entity?: EntityDirectiveResolver; 1268 | column?: ColumnDirectiveResolver; 1269 | id?: IdDirectiveResolver; 1270 | link?: LinkDirectiveResolver; 1271 | embedded?: EmbeddedDirectiveResolver; 1272 | map?: MapDirectiveResolver; 1273 | }; 1274 | 1275 | /** 1276 | * @deprecated 1277 | * Use "DirectiveResolvers" root object instead. If you wish to get "IDirectiveResolvers", add "typesPrefix: I" to your config. 1278 | */ 1279 | export type IDirectiveResolvers = DirectiveResolvers; 1280 | --------------------------------------------------------------------------------