@things-factory/gallery 0.0.0-stage → 10.1.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/client/bootstrap.ts +16 -0
  2. package/client/components/gallery-forms.ts +188 -0
  3. package/client/components/gallery-uploader.ts +557 -0
  4. package/client/components/gallery-viewer.ts +397 -0
  5. package/client/index.ts +1 -0
  6. package/client/pages/gallery-page.ts +567 -0
  7. package/client/route.ts +8 -0
  8. package/client/tsconfig.json +13 -0
  9. package/client/utils/gallery-api.ts +157 -0
  10. package/client/utils/gallery-badge.ts +44 -0
  11. package/client/utils/gallery-words.ts +22 -0
  12. package/client/utils/photo-prep.ts +93 -0
  13. package/dist-client/bootstrap.d.ts +1 -0
  14. package/dist-client/bootstrap.js +16 -0
  15. package/dist-client/bootstrap.js.map +1 -0
  16. package/dist-client/components/gallery-forms.d.ts +31 -0
  17. package/dist-client/components/gallery-forms.js +223 -0
  18. package/dist-client/components/gallery-forms.js.map +1 -0
  19. package/dist-client/components/gallery-uploader.d.ts +51 -0
  20. package/dist-client/components/gallery-uploader.js +588 -0
  21. package/dist-client/components/gallery-uploader.js.map +1 -0
  22. package/dist-client/components/gallery-viewer.d.ts +30 -0
  23. package/dist-client/components/gallery-viewer.js +416 -0
  24. package/dist-client/components/gallery-viewer.js.map +1 -0
  25. package/dist-client/index.d.ts +1 -0
  26. package/dist-client/index.js +2 -0
  27. package/dist-client/index.js.map +1 -0
  28. package/dist-client/pages/gallery-page.d.ts +55 -0
  29. package/dist-client/pages/gallery-page.js +586 -0
  30. package/dist-client/pages/gallery-page.js.map +1 -0
  31. package/dist-client/route.d.ts +1 -0
  32. package/dist-client/route.js +9 -0
  33. package/dist-client/route.js.map +1 -0
  34. package/dist-client/tsconfig.tsbuildinfo +1 -0
  35. package/dist-client/utils/gallery-api.d.ts +95 -0
  36. package/dist-client/utils/gallery-api.js +46 -0
  37. package/dist-client/utils/gallery-api.js.map +1 -0
  38. package/dist-client/utils/gallery-badge.d.ts +4 -0
  39. package/dist-client/utils/gallery-badge.js +45 -0
  40. package/dist-client/utils/gallery-badge.js.map +1 -0
  41. package/dist-client/utils/gallery-words.d.ts +7 -0
  42. package/dist-client/utils/gallery-words.js +20 -0
  43. package/dist-client/utils/gallery-words.js.map +1 -0
  44. package/dist-client/utils/photo-prep.d.ts +10 -0
  45. package/dist-client/utils/photo-prep.js +102 -0
  46. package/dist-client/utils/photo-prep.js.map +1 -0
  47. package/dist-server/attachment-reader.d.ts +1 -0
  48. package/dist-server/attachment-reader.js +13 -0
  49. package/dist-server/attachment-reader.js.map +1 -0
  50. package/dist-server/index.d.ts +1 -0
  51. package/dist-server/index.js +11 -0
  52. package/dist-server/index.js.map +1 -0
  53. package/dist-server/role-tasks.d.ts +2 -0
  54. package/dist-server/role-tasks.js +15 -0
  55. package/dist-server/role-tasks.js.map +1 -0
  56. package/dist-server/service/gallery/gallery-resolver.d.ts +29 -0
  57. package/dist-server/service/gallery/gallery-resolver.js +602 -0
  58. package/dist-server/service/gallery/gallery-resolver.js.map +1 -0
  59. package/dist-server/service/gallery/gallery-rules.d.ts +88 -0
  60. package/dist-server/service/gallery/gallery-rules.js +181 -0
  61. package/dist-server/service/gallery/gallery-rules.js.map +1 -0
  62. package/dist-server/service/gallery/gallery-service.d.ts +37 -0
  63. package/dist-server/service/gallery/gallery-service.js +234 -0
  64. package/dist-server/service/gallery/gallery-service.js.map +1 -0
  65. package/dist-server/service/gallery/gallery-types.d.ts +62 -0
  66. package/dist-server/service/gallery/gallery-types.js +227 -0
  67. package/dist-server/service/gallery/gallery-types.js.map +1 -0
  68. package/dist-server/service/gallery/gallery.d.ts +83 -0
  69. package/dist-server/service/gallery/gallery.js +330 -0
  70. package/dist-server/service/gallery/gallery.js.map +1 -0
  71. package/dist-server/service/gallery/index.d.ts +4 -0
  72. package/dist-server/service/gallery/index.js +8 -0
  73. package/dist-server/service/gallery/index.js.map +1 -0
  74. package/dist-server/service/index.d.ts +7 -0
  75. package/dist-server/service/index.js +23 -0
  76. package/dist-server/service/index.js.map +1 -0
  77. package/dist-server/tsconfig.tsbuildinfo +1 -0
  78. package/package.json +48 -4
  79. package/server/attachment-reader.ts +14 -0
  80. package/server/index.ts +9 -0
  81. package/server/role-tasks.ts +14 -0
  82. package/server/service/gallery/gallery-resolver.ts +541 -0
  83. package/server/service/gallery/gallery-rules.ts +187 -0
  84. package/server/service/gallery/gallery-service.ts +254 -0
  85. package/server/service/gallery/gallery-types.ts +151 -0
  86. package/server/service/gallery/gallery.ts +266 -0
  87. package/server/service/gallery/index.ts +5 -0
  88. package/server/service/index.ts +19 -0
  89. package/server/tsconfig.json +11 -0
  90. package/test/gallery-persistence.test.ts +372 -0
  91. package/test/gallery-rules.test.ts +140 -0
  92. package/test/open-doors.test.ts +62 -0
  93. package/test/photo-prep.test.ts +61 -0
  94. package/test/translations.test.ts +68 -0
  95. package/things-factory.config.js +8 -0
  96. package/translations/en.json +91 -0
  97. package/translations/es.json +91 -0
  98. package/translations/ja.json +91 -0
  99. package/translations/ko.json +91 -0
  100. package/translations/ms.json +91 -0
  101. package/translations/zh.json +91 -0
  102. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@things-factory/gallery",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "10.1.223",
4
+ "main": "dist-server/index.js",
5
+ "browser": "dist-client/index.js",
6
+ "things-factory": true,
7
+ "license": "MIT",
8
+ "author": "heartyoh <heartyoh@hatiolab.com>",
9
+ "description": "The company gallery - photos posted to the company, a person's own space or an event, tagged and liked by everyone of the domain",
10
+ "publishConfig": {
11
+ "access": "public",
12
+ "@things-factory:registry": "https://registry.npmjs.org"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/hatiolab/things-factory.git",
17
+ "directory": "packages/gallery"
18
+ },
19
+ "scripts": {
20
+ "build": "npm run build:server && npm run build:client",
21
+ "copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
22
+ "build:client": "npm run clean:client && npm run copy:files && npx tsc --p ./client/tsconfig.json",
23
+ "build:server": "npm run clean:server && npx tsc --p ./server/tsconfig.json",
24
+ "clean:server": "rm -rf dist-server",
25
+ "clean:client": "rm -rf dist-client",
26
+ "clean": "npm run clean:server && npm run clean:client"
27
+ },
28
+ "dependencies": {
29
+ "@material/web": "^2.0.0",
30
+ "@operato/graphql": "^10.29.39",
31
+ "@operato/i18n": "^10.0.0",
32
+ "@operato/input": "^10.29.52",
33
+ "@operato/layout": "^10.29.49",
34
+ "@operato/popup": "^10.29.39",
35
+ "@operato/shell": "^10.29.39",
36
+ "@operato/styles": "^10.29.1",
37
+ "@operato/utils": "^10.0.0",
38
+ "@things-factory/apptool-base": "^10.1.221",
39
+ "@things-factory/attachment-base": "^10.1.223",
40
+ "@things-factory/auth-base": "^10.1.223",
41
+ "@things-factory/calendar": "^10.1.223",
42
+ "@things-factory/i18n-base": "^10.1.223",
43
+ "@things-factory/notification": "^10.1.223",
44
+ "@things-factory/shell": "^10.1.208",
45
+ "graphql-tag": "^2.12.6",
46
+ "graphql-upload": "^15.0.2",
47
+ "lit": "^3.1.2"
48
+ },
49
+ "gitHead": "868f2026b8373371ad04f42b0a097964d0d0ed0f"
50
+ }
@@ -0,0 +1,14 @@
1
+ /*
2
+ * Who may open a gallery photo by its path - whoever sees the post (ADR-0165 decision 7). attachment-base has already
3
+ * checked the file is of the request's domain.
4
+ */
5
+ import { registerAttachmentReader } from '@things-factory/attachment-base'
6
+
7
+ import { GALLERY_REF_TYPE, visiblePost } from './service/gallery/gallery-service'
8
+
9
+ export function registerGalleryPhotos(): void {
10
+ registerAttachmentReader(
11
+ GALLERY_REF_TYPE,
12
+ async (attachment, context) => !!attachment.refBy && !!(await visiblePost(attachment.refBy, context))
13
+ )
14
+ }
@@ -0,0 +1,9 @@
1
+ export * from './service'
2
+
3
+ import { registerRoleTask } from '@things-factory/auth-base'
4
+
5
+ import { registerGalleryPhotos } from './attachment-reader'
6
+ import { ROLE_TASKS } from './role-tasks'
7
+
8
+ for (const task of ROLE_TASKS) registerRoleTask(task)
9
+ registerGalleryPhotos()
@@ -0,0 +1,14 @@
1
+ /*
2
+ * The work this module names, for the seats applications build (ADR-0139 decision 11).
3
+ *
4
+ * Looking, posting and liking are no task: everyone of the domain does them (ADR-0165 decision 2). Hiding a post,
5
+ * tidying the tags and making an event from the gallery are `gallery.moderation`.
6
+ *
7
+ * Data only - `server/index.ts` registers it - so an application's tests read it without starting the framework.
8
+ */
9
+
10
+ import type { RoleTask } from '@things-factory/auth-base'
11
+
12
+ export const ROLE_TASKS: RoleTask[] = [
13
+ { id: 'gallery.moderation', grants: [{ category: 'gallery', axes: ['mutation'] }] }
14
+ ]
@@ -0,0 +1,541 @@
1
+ /*
2
+ * The doors of the gallery (ADR-0165 decisions 3-7).
3
+ *
4
+ * Looking, posting and liking are no privilege: everyone of the domain sees every post (decision 2), so those doors are
5
+ * open to anyone signed in to the domain and each asks the post itself (gallery-service.ts). Changing or deleting a
6
+ * post is its owner's. Hiding, tidying tags and making an event are `gallery:mutation` - the task `gallery.moderation`.
7
+ */
8
+ import type { FileUpload } from 'graphql-upload/GraphQLUpload.js'
9
+ import GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
10
+ import { Arg, Ctx, Directive, FieldResolver, Int, Mutation, Query, Resolver, Root } from 'type-graphql'
11
+ import { In, IsNull, Not } from 'typeorm'
12
+
13
+ import { Attachment, createAttachment, deleteAttachment, deleteAttachmentsByRef } from '@things-factory/attachment-base'
14
+ import { User } from '@things-factory/auth-base'
15
+ import { createEvent, Event } from '@things-factory/calendar'
16
+ import { getRepository } from '@things-factory/shell'
17
+
18
+ import { GalleryEvent, GalleryLike, GalleryPost, GallerySpace, GalleryVisit } from './gallery'
19
+ import {
20
+ inputRefusal,
21
+ isPhoto,
22
+ MAX_TAG,
23
+ orderChoices,
24
+ photoCountRefusal,
25
+ renamedTags,
26
+ hasTag,
27
+ tagCounts,
28
+ tagOf,
29
+ tagsOf
30
+ } from './gallery-rules'
31
+ import {
32
+ decorate,
33
+ eventsSeenBetween,
34
+ GALLERY_REF_TYPE,
35
+ isModerator,
36
+ maySeeEvent,
37
+ openEvent,
38
+ photosOf,
39
+ tellFirstPhoto,
40
+ tellHidden,
41
+ viewerOf,
42
+ visiblePost
43
+ } from './gallery-service'
44
+ import {
45
+ GalleryEventCard,
46
+ GalleryEventChoice,
47
+ GalleryEventFacts,
48
+ GalleryEventInput,
49
+ GalleryFilter,
50
+ GalleryLikeState,
51
+ GalleryPerson,
52
+ GalleryPhoto,
53
+ GalleryPostInput,
54
+ GalleryPostList,
55
+ GallerySummary,
56
+ GalleryTagCount
57
+ } from './gallery-types'
58
+
59
+ const MODERATE = '@privilege(category: "gallery", privilege: "mutation", domainOwnerGranted: true)'
60
+ const DAY = 24 * 60 * 60 * 1000
61
+ /* A person who never opened the gallery hears of the last week's posts. */
62
+ const FIRST_LOOK = 7 * DAY
63
+
64
+ /** A refusal said in the reader's words, with a code the screen may read. */
65
+ function refuse(context: ResolverContext, code: string, fallback: string): never {
66
+ const message = context.t ? String(context.t(`gallery.refusal.${code}`, { defaultValue: fallback })) : fallback
67
+ const error: any = new Error(message)
68
+ error.extensions = { code: `gallery.${code}` }
69
+ throw error
70
+ }
71
+
72
+ const NOT_FOUND = 'there is no such post.'
73
+
74
+ async function visibleOrRefuse(id: string, context: ResolverContext): Promise<GalleryPost> {
75
+ const post = await visiblePost(id, context, context.state.tx)
76
+ if (!post) refuse(context, 'unknown-post', NOT_FOUND)
77
+ return post
78
+ }
79
+
80
+ async function ownedOrRefuse(id: string, context: ResolverContext): Promise<GalleryPost> {
81
+ const post = await visibleOrRefuse(id, context)
82
+ if (post.creatorId !== context.state.user.id)
83
+ refuse(context, 'not-yours', 'only the one who posted it changes or deletes it.')
84
+ return post
85
+ }
86
+
87
+ function checkInput(input: GalleryPostInput, context: ResolverContext): void {
88
+ const refusal = inputRefusal(input)
89
+ if (refusal) refuse(context, refusal, `the post cannot be kept (${refusal}).`)
90
+ }
91
+
92
+ /** The uploads, each checked to be a photo before anything is stored. */
93
+ async function photoUploads(files: FileUpload[] | undefined, context: ResolverContext): Promise<FileUpload[]> {
94
+ const out: FileUpload[] = []
95
+ for (const file of files ?? []) {
96
+ const upload = await file
97
+ if (!isPhoto(upload?.mimetype)) refuse(context, 'not-a-photo', 'only photos go in the gallery.')
98
+ out.push(file)
99
+ }
100
+ return out
101
+ }
102
+
103
+ /** The event a post may hang on - one the person may post to - opened for everyone; refused otherwise. */
104
+ async function eventOrRefuse(eventId: string, context: ResolverContext): Promise<string> {
105
+ const event = await maySeeEvent(eventId, context, context.state.tx)
106
+ if (!event) refuse(context, 'unknown-event', 'there is no such event to post to.')
107
+ await openEvent(event.id, context, context.state.tx)
108
+ return event.id
109
+ }
110
+
111
+ const factsOf = (event: Event): GalleryEventFacts => ({
112
+ id: event.id,
113
+ name: event.name,
114
+ startAt: event.startAt,
115
+ endAt: event.endAt,
116
+ location: event.location ?? undefined
117
+ })
118
+
119
+ const photoOf = (one: Attachment): GalleryPhoto => ({
120
+ id: one.id,
121
+ name: one.name,
122
+ thumbnail: one.thumbnail ?? undefined,
123
+ fullpath: one.fullpath,
124
+ mimetype: one.mimetype ?? undefined
125
+ })
126
+
127
+ /** The posts the request's person sees, as a query to narrow further. */
128
+ async function seenPosts(context: ResolverContext) {
129
+ const { domain, user } = context.state
130
+ const viewer = await viewerOf(context)
131
+ const qb = getRepository(GalleryPost).createQueryBuilder('P').where('P.domain = :domain', { domain: domain.id })
132
+ if (!viewer.moderator) qb.andWhere('(P.hiddenAt IS NULL OR P.creator = :me)', { me: user.id })
133
+ const active = [...viewer.activePeople]
134
+ /* The own gallery of someone who left is no longer shown (decision 5). */
135
+ qb.andWhere(
136
+ active.length
137
+ ? '(P.space != :personal OR P.creator = :me OR P.creator IN (:...active))'
138
+ : '(P.space != :personal OR P.creator = :me)',
139
+ {
140
+ personal: GallerySpace.PERSONAL,
141
+ me: user.id,
142
+ active
143
+ }
144
+ )
145
+ return qb
146
+ }
147
+
148
+ @Resolver(GalleryPost)
149
+ export class GalleryResolver {
150
+ /* ---------------------------------------------------------------- looking */
151
+
152
+ @Query(returns => GalleryPostList, {
153
+ description: 'The posts the person sees, newest first - narrowed by space, event, tag or person.'
154
+ })
155
+ async galleryPosts(
156
+ @Arg('filter', type => GalleryFilter, { nullable: true }) filter: GalleryFilter | undefined,
157
+ @Arg('skip', type => Int, { nullable: true }) skip: number | undefined,
158
+ @Arg('take', type => Int, { nullable: true }) take: number | undefined,
159
+ @Ctx() context: ResolverContext
160
+ ): Promise<GalleryPostList> {
161
+ const qb = await seenPosts(context)
162
+ if (filter?.space) qb.andWhere('P.space = :space', { space: filter.space })
163
+ if (filter?.eventId) qb.andWhere('P.event = :event', { event: filter.eventId })
164
+ if (filter?.authorId) qb.andWhere('P.creator = :author', { author: filter.authorId })
165
+ const tag = tagOf(filter?.tag ?? '')
166
+ /* Tags are kept as a JSON list: the quotes bound the word, so 「워크숍」 does not find 「워크숍2026」. */
167
+ if (tag) qb.andWhere('LOWER(P.tags) LIKE :tag', { tag: `%${JSON.stringify(tag.toLowerCase())}%` })
168
+ qb.orderBy('P.createdAt', 'DESC')
169
+ .skip(Math.max(0, skip ?? 0))
170
+ .take(Math.min(60, Math.max(1, take ?? 30)))
171
+ const [items, total] = await qb.getManyAndCount()
172
+ return { items: await decorate(items, context), total }
173
+ }
174
+
175
+ @Query(returns => GalleryPost, { nullable: true, description: 'One post, when the person sees it.' })
176
+ async galleryPost(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<GalleryPost | undefined> {
177
+ const post = await visiblePost(id, context)
178
+ return post ? (await decorate([post], context))[0] : undefined
179
+ }
180
+
181
+ @Query(returns => [GalleryPerson], { description: 'Who likes the post, the latest first.' })
182
+ async galleryLikers(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<GalleryPerson[]> {
183
+ const post = await visibleOrRefuse(id, context)
184
+ const likes = await getRepository(GalleryLike).find({
185
+ where: { post: { id: post.id } } as any,
186
+ relations: ['user'],
187
+ order: { createdAt: 'DESC' } as any
188
+ })
189
+ return likes.map(one => ({ id: one.userId!, name: one.user?.name }))
190
+ }
191
+
192
+ @Query(returns => [GalleryTagCount], {
193
+ description: 'The tags of the domain the most used first - those holding `text` when given.'
194
+ })
195
+ async galleryTags(
196
+ @Arg('text', { nullable: true }) text: string | undefined,
197
+ @Ctx() context: ResolverContext
198
+ ): Promise<GalleryTagCount[]> {
199
+ const rows = await getRepository(GalleryPost).find({
200
+ where: { domain: { id: context.state.domain.id }, hiddenAt: IsNull() } as any,
201
+ select: ['id', 'tags'] as any
202
+ })
203
+ return tagCounts(
204
+ rows.map(one => one.tags),
205
+ text ?? ''
206
+ )
207
+ }
208
+
209
+ @Query(returns => [GalleryEventCard], {
210
+ description: 'The events whose gallery is open, the latest first, each with its posts counted and a photo shown.'
211
+ })
212
+ async galleryEvents(@Ctx() context: ResolverContext): Promise<GalleryEventCard[]> {
213
+ const { domain } = context.state
214
+ const open = await getRepository(GalleryEvent).find({ where: { domain: { id: domain.id } } as any })
215
+ if (!open.length) return []
216
+ const events = await getRepository(Event).find({
217
+ where: { id: In(open.map(one => one.eventId)), domain: { id: domain.id } } as any,
218
+ order: { startAt: 'DESC' } as any
219
+ })
220
+ if (!events.length) return []
221
+ const posts = await (
222
+ await seenPosts(context)
223
+ )
224
+ .andWhere('P.event IN (:...events)', { events: events.map(one => one.id) })
225
+ .orderBy('P.likeCount', 'DESC')
226
+ .addOrderBy('P.createdAt', 'DESC')
227
+ .getMany()
228
+ const byEvent = new Map<string, GalleryPost[]>()
229
+ for (const one of posts) byEvent.set(one.eventId!, [...(byEvent.get(one.eventId!) ?? []), one])
230
+ const covers = await photosOf(
231
+ [...byEvent.values()].map(list => list[0].id),
232
+ domain.id
233
+ )
234
+ return events.map(event => {
235
+ const list = byEvent.get(event.id) ?? []
236
+ const cover = list.length ? covers.get(list[0].id)?.[0] : undefined
237
+ return { ...factsOf(event), postCount: list.length, cover: cover ? photoOf(cover) : undefined }
238
+ })
239
+ }
240
+
241
+ @Query(returns => [GalleryEventChoice], {
242
+ description:
243
+ 'The events the person may post photos to - those near when the photo was taken first. Their own events, the open ones, and every event for whoever reads the calendar.'
244
+ })
245
+ async galleryEventChoices(
246
+ @Arg('takenAt', { nullable: true }) takenAt: Date | undefined,
247
+ @Ctx() context: ResolverContext
248
+ ): Promise<GalleryEventChoice[]> {
249
+ const at = takenAt && !isNaN(new Date(takenAt).getTime()) ? new Date(takenAt) : new Date()
250
+ const events = await eventsSeenBetween(context, new Date(at.getTime() - 45 * DAY), new Date(at.getTime() + 3 * DAY))
251
+ return orderChoices(events.map(factsOf), at)
252
+ }
253
+
254
+ @Query(returns => GallerySummary, {
255
+ description: 'How many posts others made since the person last opened the gallery.'
256
+ })
257
+ async gallerySummary(@Ctx() context: ResolverContext): Promise<GallerySummary> {
258
+ const { domain, user } = context.state
259
+ const visit = await getRepository(GalleryVisit).findOne({
260
+ where: { domain: { id: domain.id }, user: { id: user.id } } as any
261
+ })
262
+ const since = visit?.seenAt ?? new Date(Date.now() - FIRST_LOOK)
263
+ const fresh = await (
264
+ await seenPosts(context)
265
+ )
266
+ .andWhere('P.createdAt > :since', { since })
267
+ .andWhere('P.creator != :me', { me: user.id })
268
+ .getCount()
269
+ return { fresh }
270
+ }
271
+
272
+ /* ---------------------------------------------------------------- posting */
273
+
274
+ @Directive('@transaction')
275
+ @Mutation(returns => GalleryPost, {
276
+ description: 'Posts photos - at least one - with a caption, tags and an event when given.'
277
+ })
278
+ async createGalleryPost(
279
+ @Arg('input', type => GalleryPostInput) input: GalleryPostInput,
280
+ @Arg('photos', type => [GraphQLUpload]) photos: FileUpload[],
281
+ @Ctx() context: ResolverContext
282
+ ): Promise<GalleryPost> {
283
+ const { domain, user, tx } = context.state as any
284
+ checkInput(input, context)
285
+ const countRefusal = photoCountRefusal(photos?.length ?? 0)
286
+ if (countRefusal) refuse(context, countRefusal, `a post holds 1 to 20 photos (${countRefusal}).`)
287
+ const uploads = await photoUploads(photos, context)
288
+ const eventId = input.eventId ? await eventOrRefuse(input.eventId, context) : null
289
+ const post = await getRepository(GalleryPost, tx).save({
290
+ domain,
291
+ space: eventId ? GallerySpace.COMPANY : (input.space ?? GallerySpace.COMPANY),
292
+ caption: input.caption?.trim() || null,
293
+ takenAt: input.takenAt ?? new Date(),
294
+ tags: tagsOf(input.tags),
295
+ event: eventId ? { id: eventId } : null,
296
+ likeCount: 0,
297
+ creator: user,
298
+ updater: user
299
+ } as any)
300
+ for (const file of uploads)
301
+ await createAttachment(null, { attachment: { file, refType: GALLERY_REF_TYPE, refBy: post.id } } as any, context)
302
+ if (eventId) await tellFirstPhoto(context, post, eventId)
303
+ return (await decorate([await getRepository(GalleryPost, tx).findOneBy({ id: post.id })], context))[0]
304
+ }
305
+
306
+ @Directive('@transaction')
307
+ @Mutation(returns => GalleryPost, {
308
+ description: "Changes one's own post - its caption, tags, space, event and photos; at least one photo stays."
309
+ })
310
+ async updateGalleryPost(
311
+ @Arg('id') id: string,
312
+ @Arg('input', type => GalleryPostInput) input: GalleryPostInput,
313
+ @Arg('attach', type => [GraphQLUpload], { nullable: true }) attach: FileUpload[] | undefined,
314
+ @Arg('remove', type => [String], { nullable: true }) remove: string[] | undefined,
315
+ @Ctx() context: ResolverContext
316
+ ): Promise<GalleryPost> {
317
+ const { domain, user, tx } = context.state as any
318
+ const post = await ownedOrRefuse(id, context)
319
+ checkInput(input, context)
320
+ const current = (await photosOf([post.id], domain.id, tx)).get(post.id) ?? []
321
+ const removing = current.filter(one => (remove ?? []).includes(one.id))
322
+ const countRefusal = photoCountRefusal(current.length - removing.length + (attach?.length ?? 0))
323
+ if (countRefusal) refuse(context, countRefusal, `a post holds 1 to 20 photos (${countRefusal}).`)
324
+ const uploads = await photoUploads(attach, context)
325
+ const eventId = input.eventId
326
+ ? input.eventId === post.eventId
327
+ ? post.eventId
328
+ : await eventOrRefuse(input.eventId, context)
329
+ : null
330
+ await getRepository(GalleryPost, tx).update(post.id, {
331
+ space: eventId ? GallerySpace.COMPANY : (input.space ?? post.space),
332
+ caption: input.caption?.trim() || null,
333
+ takenAt: input.takenAt ?? post.takenAt,
334
+ tags: tagsOf(input.tags),
335
+ event: eventId ? { id: eventId } : null,
336
+ updater: user
337
+ } as any)
338
+ for (const one of removing) await deleteAttachment(null, { id: one.id }, context)
339
+ for (const file of uploads)
340
+ await createAttachment(null, { attachment: { file, refType: GALLERY_REF_TYPE, refBy: post.id } } as any, context)
341
+ if (eventId && eventId !== post.eventId) await tellFirstPhoto(context, post, eventId)
342
+ return (await decorate([await getRepository(GalleryPost, tx).findOneBy({ id: post.id })], context))[0]
343
+ }
344
+
345
+ @Directive('@transaction')
346
+ @Mutation(returns => Boolean, { description: "Deletes one's own post with its photos." })
347
+ async deleteGalleryPost(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {
348
+ const { tx, user } = context.state as any
349
+ const post = await ownedOrRefuse(id, context)
350
+ await deleteAttachmentsByRef(null, { refBys: [post.id], refType: GALLERY_REF_TYPE }, context)
351
+ await getRepository(GalleryLike, tx).softDelete({ post: { id: post.id } } as any)
352
+ await getRepository(GalleryPost, tx).update(post.id, { updater: user } as any)
353
+ await getRepository(GalleryPost, tx).softDelete(post.id)
354
+ return true
355
+ }
356
+
357
+ @Directive('@transaction')
358
+ @Mutation(returns => GalleryLikeState, {
359
+ description: 'Likes a post, or takes the like back - liking it twice changes nothing.'
360
+ })
361
+ async likeGalleryPost(
362
+ @Arg('id') id: string,
363
+ @Arg('like') like: boolean,
364
+ @Ctx() context: ResolverContext
365
+ ): Promise<GalleryLikeState> {
366
+ const { domain, user, tx } = context.state as any
367
+ const post = await visibleOrRefuse(id, context)
368
+ const repository = getRepository(GalleryLike, tx)
369
+ const mine = await repository.findOne({ where: { post: { id: post.id }, user: { id: user.id } } as any })
370
+ if (like && !mine)
371
+ await repository.save({
372
+ domain,
373
+ post: { id: post.id },
374
+ user: { id: user.id },
375
+ creator: user,
376
+ updater: user
377
+ } as any)
378
+ if (!like && mine) await repository.softDelete(mine.id)
379
+ const likeCount = await repository.count({ where: { post: { id: post.id } } as any })
380
+ await getRepository(GalleryPost, tx).update(post.id, { likeCount } as any)
381
+ return { id: post.id, likeCount, liked: like }
382
+ }
383
+
384
+ @Directive('@transaction')
385
+ @Mutation(returns => Boolean, {
386
+ description: 'Records that the person opened the gallery - the menu counts the posts after it.'
387
+ })
388
+ async visitGallery(@Ctx() context: ResolverContext): Promise<boolean> {
389
+ const { domain, user, tx } = context.state as any
390
+ const repository = getRepository(GalleryVisit, tx)
391
+ const visit = await repository.findOne({ where: { domain: { id: domain.id }, user: { id: user.id } } as any })
392
+ if (visit) await repository.update(visit.id, { seenAt: new Date(), updater: user } as any)
393
+ else
394
+ await repository.save({ domain, user: { id: user.id }, seenAt: new Date(), creator: user, updater: user } as any)
395
+ return true
396
+ }
397
+
398
+ /* ---------------------------------------------------------------- moderating */
399
+
400
+ @Directive(MODERATE)
401
+ @Directive('@transaction')
402
+ @Mutation(returns => GalleryPost, {
403
+ description:
404
+ 'Hides a post with the reason - its owner is told, and still sees it marked hidden. Undone by unhideGalleryPost.'
405
+ })
406
+ async hideGalleryPost(
407
+ @Arg('id') id: string,
408
+ @Arg('reason') reason: string,
409
+ @Ctx() context: ResolverContext
410
+ ): Promise<GalleryPost> {
411
+ const { user, tx } = context.state as any
412
+ const post = await visibleOrRefuse(id, context)
413
+ const why = (reason ?? '').trim()
414
+ if (!why) refuse(context, 'reason-required', 'give the reason it is hidden.')
415
+ if (why.length > 200) refuse(context, 'reason-too-long', 'the reason is at most 200 characters.')
416
+ await getRepository(GalleryPost, tx).update(post.id, {
417
+ hiddenAt: new Date(),
418
+ hiddenReason: why,
419
+ hiddenBy: user,
420
+ updater: user
421
+ } as any)
422
+ await tellHidden(context, post, why)
423
+ return (await decorate([await getRepository(GalleryPost, tx).findOneBy({ id: post.id })], context))[0]
424
+ }
425
+
426
+ @Directive(MODERATE)
427
+ @Directive('@transaction')
428
+ @Mutation(returns => GalleryPost, { description: 'Shows a hidden post again.' })
429
+ async unhideGalleryPost(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<GalleryPost> {
430
+ const { user, tx } = context.state as any
431
+ const post = await visibleOrRefuse(id, context)
432
+ await getRepository(GalleryPost, tx).update(post.id, {
433
+ hiddenAt: null,
434
+ hiddenReason: null,
435
+ hiddenBy: null,
436
+ updater: user
437
+ } as any)
438
+ return (await decorate([await getRepository(GalleryPost, tx).findOneBy({ id: post.id })], context))[0]
439
+ }
440
+
441
+ @Directive(MODERATE)
442
+ @Directive('@transaction')
443
+ @Mutation(returns => Int, {
444
+ description:
445
+ 'Renames a tag on every post of the domain - into a tag already there, the two become one. Answers how many posts changed.'
446
+ })
447
+ async renameGalleryTag(
448
+ @Arg('from') from: string,
449
+ @Arg('to') to: string,
450
+ @Ctx() context: ResolverContext
451
+ ): Promise<number> {
452
+ const { domain, user, tx } = context.state as any
453
+ const target = tagOf(to)
454
+ if (!tagOf(from) || !target) refuse(context, 'tag-required', 'give the tag and its new name.')
455
+ if (target.length > MAX_TAG) refuse(context, 'tag-too-long', 'a tag is at most 30 characters.')
456
+ const repository = getRepository(GalleryPost, tx)
457
+ const rows = await repository.find({
458
+ where: { domain: { id: domain.id }, tags: Not(IsNull()) } as any,
459
+ select: ['id', 'tags'] as any
460
+ })
461
+ let changed = 0
462
+ for (const one of rows) {
463
+ if (!hasTag(one.tags, from)) continue
464
+ await repository.update(one.id, { tags: renamedTags(one.tags, from, target), updater: user } as any)
465
+ changed++
466
+ }
467
+ return changed
468
+ }
469
+
470
+ @Directive(MODERATE)
471
+ @Directive('@transaction')
472
+ @Mutation(returns => GalleryEventCard, {
473
+ description:
474
+ 'Makes a company event on the calendar and opens its gallery - for the events nobody put on the calendar yet.'
475
+ })
476
+ async createGalleryEvent(
477
+ @Arg('input', type => GalleryEventInput) input: GalleryEventInput,
478
+ @Ctx() context: ResolverContext
479
+ ): Promise<GalleryEventCard> {
480
+ const { domain, user, tx } = context.state as any
481
+ const event = await createEvent(tx, domain, user, {
482
+ name: input.name,
483
+ startAt: input.startAt,
484
+ endAt: input.endAt,
485
+ location: input.location
486
+ } as any)
487
+ await openEvent(event.id, context, tx)
488
+ return { ...factsOf(event), postCount: 0 }
489
+ }
490
+
491
+ /* ---------------------------------------------------------------- a post's fields */
492
+
493
+ @FieldResolver(type => [GalleryPhoto], { description: 'The photos, in the order they were added.' })
494
+ async photos(@Root() post: GalleryPost, @Ctx() context: ResolverContext): Promise<GalleryPhoto[]> {
495
+ const rows = (post as any).__photos ?? (await photosOf([post.id], context.state.domain.id)).get(post.id) ?? []
496
+ return rows.map(photoOf)
497
+ }
498
+
499
+ @FieldResolver(type => User, { nullable: true, description: 'Who posted it - the owner.' })
500
+ async creator(@Root() post: GalleryPost): Promise<User | undefined> {
501
+ if ((post as any).__creator !== undefined) return (post as any).__creator ?? undefined
502
+ return post.creatorId ? await getRepository(User).findOneBy({ id: post.creatorId }) : undefined
503
+ }
504
+
505
+ @FieldResolver(type => GalleryEventFacts, {
506
+ nullable: true,
507
+ description: 'The event the photos are of - its name, time and place.'
508
+ })
509
+ async event(@Root() post: GalleryPost): Promise<GalleryEventFacts | undefined> {
510
+ const event =
511
+ (post as any).__event ?? (post.eventId ? await getRepository(Event).findOneBy({ id: post.eventId }) : null)
512
+ return event ? factsOf(event) : undefined
513
+ }
514
+
515
+ @FieldResolver(type => Boolean, { description: 'Whether the person likes it.' })
516
+ async liked(@Root() post: GalleryPost, @Ctx() context: ResolverContext): Promise<boolean> {
517
+ if ((post as any).__liked !== undefined) return (post as any).__liked
518
+ return !!(await getRepository(GalleryLike).findOne({
519
+ where: { post: { id: post.id }, user: { id: context.state.user.id } } as any
520
+ }))
521
+ }
522
+
523
+ @FieldResolver(type => Boolean, { description: "Whether it is the person's own - they change and delete it." })
524
+ editable(@Root() post: GalleryPost, @Ctx() context: ResolverContext): boolean {
525
+ return post.creatorId === context.state.user?.id
526
+ }
527
+
528
+ @FieldResolver(type => Boolean, { description: 'Whether the person may hide it or show it again.' })
529
+ async moderatable(@Ctx() context: ResolverContext): Promise<boolean> {
530
+ return await isModerator(context)
531
+ }
532
+
533
+ @FieldResolver(type => String, {
534
+ nullable: true,
535
+ description: 'Why it was hidden - for its owner and the moderators.'
536
+ })
537
+ async hiddenReason(@Root() post: GalleryPost, @Ctx() context: ResolverContext): Promise<string | undefined> {
538
+ if (!post.hiddenAt) return undefined
539
+ return post.creatorId === context.state.user?.id || (await isModerator(context)) ? post.hiddenReason : undefined
540
+ }
541
+ }