Skip to content

Commit a66a6b1

Browse files
committed
Merge branch 'develop'
2 parents 8897d85 + 1875b0e commit a66a6b1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+1788
-96
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
"@types/cookie-parser": "^1.4.3",
107107
"@types/express": "^4.17.17",
108108
"@types/express-serve-static-core": "^4.17.35",
109-
"@types/ffprobe": "^1.1.4",
109+
"@types/ffprobe": "^1.1.5",
110110
"@types/jest": "^29.5.3",
111111
"@types/jsonwebtoken": "^9.0.2",
112112
"@types/lodash": "^4.14.197",

src/common/create-and-inject.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ModuleRef } from '@nestjs/core';
2+
import { pickBy } from 'lodash';
3+
import { Class } from 'type-fest';
4+
5+
/**
6+
* A helper to create an instance of a class and inject dependencies.
7+
*/
8+
export async function createAndInject<T extends Class<any>>(
9+
moduleRef: ModuleRef,
10+
type: T,
11+
...input: ConstructorParameters<T>
12+
): Promise<InstanceType<T>> {
13+
const injection = await moduleRef.resolve(type);
14+
const injectionProps = pickBy(injection);
15+
const object = new type(...input);
16+
Object.assign(object, injectionProps);
17+
return object;
18+
}

src/common/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export { Many, many, maybeMany, JsonSet, ArrayItem } from '@seedcompany/common';
33
export * from './temporal';
44
export * from './calculated.decorator';
55
export * from './context.type';
6+
export * from './create-and-inject';
67
export * from './data-object';
78
export * from './date-filter.input';
89
export { DbLabel } from './db-label.decorator';
@@ -26,6 +27,7 @@ export * from './pagination.input';
2627
export * from './pagination-list';
2728
export * from './parent-id.middleware';
2829
export * from './parent-types';
30+
export * from './poll';
2931
export * from './resource.dto';
3032
export * from './role.dto';
3133
export * from './secured-list';

src/common/poll.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { setOf } from '@seedcompany/common';
2+
3+
export class PollResults<T> {
4+
constructor(protected readonly data: PollData<T>) {}
5+
6+
/** Returns true if there were any votes */
7+
get anyVotes() {
8+
return this.numberOfVotes > 0;
9+
}
10+
11+
/** Returns true if there were no votes */
12+
get noVotes() {
13+
return this.numberOfVotes === 0;
14+
}
15+
16+
get numberOfVotes() {
17+
return [...this.data.votes.values()].reduce((total, cur) => total + cur, 0);
18+
}
19+
20+
get vetoed() {
21+
return this.data.vetoed;
22+
}
23+
24+
/** Returns if there was a tie for the highest votes */
25+
get tie() {
26+
const [highest, second] = this.sorted;
27+
return highest && second ? highest[1] === second[1] : false;
28+
}
29+
30+
/** Returns the largest minority vote (could be majority too), if there was one */
31+
get plurality() {
32+
const [highest, second] = this.sorted;
33+
if (!highest) {
34+
return undefined;
35+
}
36+
return highest[1] > (second?.[1] ?? 0) ? highest[0] : undefined;
37+
}
38+
39+
/** Returns the majority vote (>50%), if there was one */
40+
get majority() {
41+
const [first] = this.sorted;
42+
if (!first) {
43+
return undefined;
44+
}
45+
return first[1] > this.numberOfVotes / 2 ? first[0] : undefined;
46+
}
47+
48+
/** Returns the unanimous vote, if there was one */
49+
get unanimous() {
50+
const all = this.sorted;
51+
return all.length === 1 ? all[0][0] : undefined;
52+
}
53+
54+
/** Returns all votes sorted by most voted first (ties are unaccounted for) */
55+
get allVotes() {
56+
return setOf(this.sorted.map(([vote]) => vote));
57+
}
58+
59+
private get sorted() {
60+
return [...this.data.votes].sort((a, b) => b[1] - a[1]);
61+
}
62+
}
63+
64+
/**
65+
* @example
66+
* const poll = new Poll();
67+
*
68+
* poll.noVotes; // true
69+
* poll.vote(true);
70+
* poll.unanimous; // true
71+
* poll.anyVotes; // true
72+
*
73+
* poll.vote(false);
74+
* poll.unanimous; // undefined
75+
* poll.tie; // true
76+
* poll.majority; // undefined
77+
* poll.plurality; // undefined
78+
*
79+
* poll.vote(true);
80+
* poll.majority; // true
81+
* poll.plurality; // true
82+
*/
83+
export class Poll<T = boolean> extends PollResults<T> implements PollVoter<T> {
84+
// Get a view of this poll, with results hidden.
85+
readonly voter: PollVoter<T> = this;
86+
// Get a readonly view of this poll's results.
87+
readonly results: PollResults<T> = this;
88+
89+
constructor() {
90+
super(new PollData<T>());
91+
}
92+
93+
vote(vote: T) {
94+
this.data.votes.set(vote, (this.data.votes.get(vote) ?? 0) + 1);
95+
}
96+
97+
veto() {
98+
this.data.vetoed = true;
99+
}
100+
}
101+
102+
/**
103+
* The mutations available for a poll.
104+
*/
105+
export abstract class PollVoter<T> {
106+
/** Cast a vote. */
107+
abstract vote(vote: T): void;
108+
109+
/**
110+
* Veto the poll all together.
111+
* Multiple vetoes are allowed and are functionally the same.
112+
* Consider using this instead of throwing an exception, for when you want to
113+
* "cancel" the poll / override all other votes.
114+
* Exceptions are for unexpected errors, where this veto would be a logical
115+
* expectation, so throwing is not the best way to handle it.
116+
* This could be enhanced in future to allow a reason for the veto.
117+
*/
118+
abstract veto(): void;
119+
}
120+
121+
class PollData<T> {
122+
votes = new Map<T, number>();
123+
vetoed = false;
124+
}

src/common/variant.dto.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,22 +72,29 @@ export type VariantOf<TResourceStatic extends ResourceShape<any>> =
7272
*/
7373
export const VariantInputField = <
7474
Res extends ResourceShape<any> & { Variants: readonly Variant[] },
75+
Many extends undefined | true = undefined,
7576
>(
7677
resource: Res,
7778
options: Omit<FieldOptions, 'defaultValue'> & {
78-
defaultValue?: Variant<VariantOf<Res>> | VariantOf<Res>;
79+
many?: Many;
80+
defaultValue?: Many extends true
81+
? ReadonlyArray<Variant<VariantOf<Res>> | VariantOf<Res>>
82+
: Variant<VariantOf<Res>> | VariantOf<Res>;
7983
} = {},
8084
) => {
81-
const { defaultValue, ...rest } = options;
85+
const { many, defaultValue, ...rest } = options;
8286

8387
// Resolve default to variant object
84-
const defaultVariant =
85-
typeof defaultValue === 'string'
86-
? resource.Variants.find((v) => v.key === options.defaultValue)
87-
: defaultValue;
88+
const resolveVariant = (value: Variant<VariantOf<Res>> | VariantOf<Res>) =>
89+
typeof value === 'string'
90+
? resource.Variants.find((v) => v.key === value)
91+
: value;
92+
const defaultVariant = many
93+
? (defaultValue as any[])?.map(resolveVariant)
94+
: resolveVariant(defaultValue as VariantOf<Res>);
8895

8996
return applyDecorators(
90-
Field(() => IDType, {
97+
Field(() => (many ? [IDType] : IDType), {
9198
// Don't put default value in schema as we are trying to keep specific
9299
// values out of schema, so they can be more dynamic.
93100
nullable: !!defaultValue,
@@ -97,11 +104,15 @@ export const VariantInputField = <
97104
if (value == null) {
98105
return defaultVariant;
99106
}
100-
return resource.Variants.find((v) => v.key === value) ?? value;
107+
if (many && Array.isArray(value)) {
108+
return value.map(resolveVariant);
109+
}
110+
return resolveVariant(value);
101111
}),
102112
IsIn(resource.Variants, {
103113
message: ({ value }) =>
104114
`Variant with key "${String(value)}" was not found`,
115+
each: many,
105116
}),
106117
);
107118
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { owner, Policy } from '../util';
2+
3+
@Policy('all', (r) => r.ProgressReportMedia.when(owner).edit.delete)
4+
export class ProgressReportMediaOwnerPolicy {}

src/components/authorization/policies/by-role/consultant-manager.policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { member, Policy, Role, sensMediumOrLower, sensOnlyLow } from '../util';
4646
r.ProgressReportCommunityStory,
4747
r.ProgressReportHighlight,
4848
r.ProgressReportTeamNews,
49+
r.ProgressReportMedia,
4950
r.ProgressReportVarianceExplanation,
5051
r.StepProgress,
5152
].map((it) => it.whenAny(member, sensMediumOrLower).read),

src/components/authorization/policies/by-role/consultant.policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { member, Policy, Role } from '../util';
1717
r.ProgressReportCommunityStory,
1818
r.ProgressReportHighlight,
1919
r.ProgressReportTeamNews,
20+
r.ProgressReportMedia,
2021
r.ProgressReportVarianceExplanation,
2122
r.StepProgress,
2223
].map((it) => it.when(member).read),

src/components/authorization/policies/by-role/field-partner.policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { member, Policy, Role, variant } from '../util';
2424
p.responses.whenAll(member, variant('draft')).edit,
2525
]),
2626
]),
27+
r.ProgressReportMedia.whenAll(member, variant('draft')).create.edit,
2728
r.ProgressReportWorkflowEvent.transitions(
2829
'Start',
2930
'In Progress -> In Review',

src/components/authorization/policies/by-role/marketing.policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
p.responses.read.when(variant('published')).edit,
2828
]),
2929
]),
30+
r.ProgressReportMedia.read.when(variant('published')).create.edit,
3031
r.ProgressReportVarianceExplanation.read.specifically((p) => p.comments.none),
3132
r.ProgressReportWorkflowEvent.read.transitions('Publish').execute,
3233
r.Project.read

0 commit comments

Comments
 (0)