Skip to content

Commit e027fd3

Browse files
authored
Merge pull request #2887 from SeedCompany/media-1.1
2 parents 53085a0 + bf2dc3d commit e027fd3

File tree

9 files changed

+283
-19
lines changed

9 files changed

+283
-19
lines changed

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+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
2+
import { CachedByArg as Once } from '@seedcompany/common';
3+
import { PollVoter } from '~/common';
4+
import { ResourceResolver, ResourcesHost } from '~/core';
5+
import { AnyMedia, MediaUserMetadata } from '../media.dto';
6+
7+
/**
8+
* An attempt to update the media metadata.
9+
* Vote with `allowUpdate` to control whether the update is allowed.
10+
*/
11+
@Injectable({ scope: Scope.TRANSIENT })
12+
export class CanUpdateMediaUserMetadataEvent {
13+
@Inject() private readonly resourceHost: ResourcesHost;
14+
@Inject() private readonly resourceResolver: ResourceResolver;
15+
16+
constructor(
17+
@Optional() readonly media: AnyMedia,
18+
@Optional() readonly input: MediaUserMetadata,
19+
@Optional() readonly allowUpdate: PollVoter<boolean>,
20+
) {}
21+
22+
@Once() async getAttachedResource() {
23+
const attachedResName = this.resourceResolver.resolveTypeByBaseNode(
24+
this.media.attachedTo[0],
25+
);
26+
const attachedResource = await this.resourceHost.getByName(attachedResName);
27+
return attachedResource;
28+
}
29+
}

src/components/file/media/media.dto.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
ServerException,
2121
simpleSwitch,
2222
} from '~/common';
23+
import { BaseNode } from '~/core/database/results';
24+
import { RegisterResource } from '~/core/resources';
2325
import { FileVersion } from '../dto';
2426

2527
export type AnyMedia = Image | Video | Audio;
@@ -67,6 +69,7 @@ export class MediaUserMetadata extends DataObject {
6769
@InterfaceType({
6870
resolveType: resolveMedia,
6971
})
72+
@RegisterResource()
7073
export class Media extends MediaUserMetadata {
7174
static readonly Props: string[] = keysOf<Media>();
7275
static readonly SecuredProps: string[] = keysOf<SecuredProps<Media>>();
@@ -78,6 +81,9 @@ export class Media extends MediaUserMetadata {
7881

7982
readonly file: IdOf<FileVersion>;
8083

84+
/** The resource that holds the root file node that this media is attached to */
85+
readonly attachedTo: [resource: BaseNode, relation: string];
86+
8187
@Field(() => String)
8288
readonly mimeType: string;
8389
}
@@ -137,3 +143,9 @@ export class Audio extends TemporalMedia {
137143

138144
declare __typename: 'Audio';
139145
}
146+
147+
declare module '~/core/resources/map' {
148+
interface ResourceMap {
149+
Media: typeof Media;
150+
}
151+
}

src/components/file/media/media.loader.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { DataLoaderStrategy } from '@seedcompany/data-loader';
22
import { ID } from '~/common';
33
import { LoaderFactory } from '~/core/resources';
4-
import { AnyMedia } from './media.dto';
4+
import { AnyMedia, Media } from './media.dto';
55
import { MediaRepository } from './media.repository';
66

7-
@LoaderFactory()
7+
@LoaderFactory(() => Media)
88
export class MediaLoader implements DataLoaderStrategy<AnyMedia, ID> {
99
constructor(private readonly repo: MediaRepository) {}
1010

src/components/file/media/media.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
22
import { FileModule } from '../file.module';
33
import { DetectExistingMediaMigration } from './detect-existing-media.migration';
44
import { DimensionsResolver } from './dimensions.resolver';
5+
import { CanUpdateMediaUserMetadataEvent } from './events/can-update-event';
56
import { MediaByFileVersionLoader } from './media-by-file-version.loader';
67
import { MediaDetector } from './media-detector.service';
78
import { MediaLoader } from './media.loader';
@@ -20,6 +21,7 @@ import { MediaService } from './media.service';
2021
MediaResolver,
2122
MediaService,
2223
DetectExistingMediaMigration,
24+
CanUpdateMediaUserMetadataEvent,
2325
],
2426
exports: [MediaService],
2527
})

src/components/file/media/media.repository.ts

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
import { Injectable } from '@nestjs/common';
22
import { inArray, node, or, Query, relation } from 'cypher-query-builder';
3-
import { RequireAtLeastOne } from 'type-fest';
4-
import { EnhancedResource, generateId, ID, ServerException } from '~/common';
3+
import { Except, RequireAtLeastOne } from 'type-fest';
4+
import {
5+
EnhancedResource,
6+
generateId,
7+
ID,
8+
NotFoundException,
9+
ServerException,
10+
} from '~/common';
511
import { CommonRepository } from '~/core';
612
import { ACTIVE, apoc, merge } from '~/core/database/query';
713
import { AnyMedia, MediaUserMetadata, resolveMedia } from './media.dto';
814

915
@Injectable()
1016
export class MediaRepository extends CommonRepository {
17+
async readOne(input: RequireAtLeastOne<Pick<AnyMedia, 'id' | 'file'>>) {
18+
const [media] = await this.readMany(
19+
input.id ? { mediaIds: [input.id] } : { fvIds: [input.file!] },
20+
);
21+
if (!media) {
22+
throw new NotFoundException('Media not found');
23+
}
24+
return media;
25+
}
26+
1127
async readMany(
1228
input: RequireAtLeastOne<Record<'fvIds' | 'mediaIds', readonly ID[]>>,
1329
) {
@@ -31,20 +47,39 @@ export class MediaRepository extends CommonRepository {
3147

3248
protected hydrate() {
3349
return (query: Query) =>
34-
query.return<{ dto: AnyMedia }>(
35-
merge('node', {
36-
__typename: 'node.type',
37-
file: 'fv.id',
38-
dimensions: {
39-
width: 'node.width',
40-
height: 'node.height',
41-
},
42-
}).as('dto'),
43-
);
50+
query
51+
.subQuery('fv', (sub) =>
52+
sub
53+
.comment('Find root file node')
54+
.subQuery('fv', (sub2) =>
55+
sub2
56+
.raw('MATCH p=(fv)-[:parent*]->(node:FileNode)')
57+
.return('node as root')
58+
.orderBy('length(p)', 'DESC')
59+
.raw('LIMIT 1'),
60+
)
61+
.comment('Get resource holding root file node')
62+
.raw('MATCH (resource:BaseNode)-[rel]->(root)')
63+
.raw('WHERE not resource:FileNode')
64+
.return('[resource, type(rel)] as attachedTo')
65+
.raw('LIMIT 1'),
66+
)
67+
.return<{ dto: AnyMedia }>(
68+
merge('node', {
69+
__typename: 'node.type',
70+
file: 'fv.id',
71+
dimensions: {
72+
width: 'node.width',
73+
height: 'node.height',
74+
},
75+
attachedTo: 'attachedTo',
76+
}).as('dto'),
77+
);
4478
}
4579

4680
async save(
47-
input: RequireAtLeastOne<Pick<AnyMedia, 'id' | 'file'>> & Partial<AnyMedia>,
81+
input: RequireAtLeastOne<Pick<AnyMedia, 'id' | 'file'>> &
82+
Partial<Except<AnyMedia, 'attachedTo'>>,
4883
) {
4984
const res = input.__typename
5085
? EnhancedResource.of(resolveMedia(input as AnyMedia))
@@ -119,10 +154,24 @@ export class MediaRepository extends CommonRepository {
119154
.apply(this.hydrate());
120155

121156
const result = await query.first();
122-
if (!result) {
123-
throw new ServerException('Failed to save media info');
157+
if (result) {
158+
return result.dto;
159+
}
160+
if (input.file) {
161+
const exists = await this.getBaseNode(input.file, 'FileVersion');
162+
if (!exists) {
163+
throw new NotFoundException(
164+
'Media could not be saved to nonexistent file',
165+
);
166+
}
167+
}
168+
if (input.id) {
169+
const exists = await this.getBaseNode(input.id, 'Media');
170+
if (!exists) {
171+
throw new NotFoundException('Media could not be found');
172+
}
124173
}
125-
return result.dto;
174+
throw new ServerException('Failed to save media info');
126175
}
127176
}
128177

0 commit comments

Comments
 (0)