@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
@@ -0,0 +1,187 @@
1
+ /*
2
+ * The gallery's rules that need no database (ADR-0165): what a post may hold, how tags are kept, who sees a post, which
3
+ * events are offered for a photo, and who hears of an event's first photo. Pure, so the tests read them directly.
4
+ */
5
+
6
+ export const SPACES = ['COMPANY', 'PERSONAL']
7
+
8
+ /** At most this many photos in one post, and at least one. */
9
+ export const MAX_PHOTOS = 20
10
+ export const MAX_CAPTION = 500
11
+ export const MAX_TAGS = 10
12
+ export const MAX_TAG = 30
13
+
14
+ /** A tag as it is kept: no `#` in front, inner spaces as one, nothing around. */
15
+ export function tagOf(raw: string): string {
16
+ return String(raw ?? '')
17
+ .trim()
18
+ .replace(/^#+/, '')
19
+ .replace(/\s+/g, ' ')
20
+ .trim()
21
+ }
22
+
23
+ /** The tags as they are kept - each once (the same word whatever its case), in the order given. */
24
+ export function tagsOf(raw: string[] | null | undefined): string[] {
25
+ const seen = new Set<string>()
26
+ const out: string[] = []
27
+ for (const one of raw ?? []) {
28
+ const tag = tagOf(one)
29
+ const key = tag.toLowerCase()
30
+ if (!tag || seen.has(key)) continue
31
+ seen.add(key)
32
+ out.push(tag)
33
+ }
34
+ return out
35
+ }
36
+
37
+ export type PostInput = {
38
+ space?: string | null
39
+ caption?: string | null
40
+ tags?: string[] | null
41
+ eventId?: string | null
42
+ }
43
+
44
+ /** Why the input cannot be kept, or nothing. */
45
+ export function inputRefusal(input: PostInput): string | undefined {
46
+ if (input.space && !SPACES.includes(input.space)) return 'unknown-space'
47
+ if ((input.caption ?? '').length > MAX_CAPTION) return 'caption-too-long'
48
+ const tags = tagsOf(input.tags)
49
+ if (tags.length > MAX_TAGS) return 'too-many-tags'
50
+ if (tags.some(tag => tag.length > MAX_TAG)) return 'tag-too-long'
51
+ /* An event is the company's: a person's own gallery holds their days, not the company's events (decision 2). */
52
+ if (input.eventId && input.space === 'PERSONAL') return 'event-in-personal'
53
+ return undefined
54
+ }
55
+
56
+ /** Why a post would hold this many photos wrongly, or nothing. */
57
+ export function photoCountRefusal(count: number): string | undefined {
58
+ if (count < 1) return 'no-photo'
59
+ if (count > MAX_PHOTOS) return 'too-many-photos'
60
+ return undefined
61
+ }
62
+
63
+ /** A photo is an image - the gallery takes nothing else. */
64
+ export function isPhoto(mimetype?: string | null): boolean {
65
+ return /^image\//i.test(mimetype ?? '')
66
+ }
67
+
68
+ /** The tags with `from` renamed to `to` - a rename into a tag already there merges the two. */
69
+ export function renamedTags(tags: string[] | null | undefined, from: string, to: string): string[] {
70
+ const source = tagOf(from).toLowerCase()
71
+ return tagsOf((tags ?? []).map(tag => (tagOf(tag).toLowerCase() === source ? to : tag)))
72
+ }
73
+
74
+ /** Whether a post holds the tag, whatever its case. */
75
+ export function hasTag(tags: string[] | null | undefined, tag: string): boolean {
76
+ const key = tagOf(tag).toLowerCase()
77
+ return !!key && (tags ?? []).some(one => tagOf(one).toLowerCase() === key)
78
+ }
79
+
80
+ /** The tags of the domain with how many posts hold each, the most used first - narrowed to those that hold `text`. */
81
+ export function tagCounts(
82
+ lists: (string[] | null | undefined)[],
83
+ text = '',
84
+ limit = 20
85
+ ): { tag: string; count: number }[] {
86
+ const counts = new Map<string, { tag: string; count: number }>()
87
+ for (const list of lists) {
88
+ for (const tag of tagsOf(list)) {
89
+ const key = tag.toLowerCase()
90
+ const one = counts.get(key)
91
+ if (one) one.count++
92
+ else counts.set(key, { tag, count: 1 })
93
+ }
94
+ }
95
+ const wanted = tagOf(text).toLowerCase()
96
+ return [...counts.values()]
97
+ .filter(one => !wanted || one.tag.toLowerCase().includes(wanted))
98
+ .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
99
+ .slice(0, limit)
100
+ }
101
+
102
+ export type Viewer = { userId: string; moderator: boolean; activePeople: ReadonlySet<string> }
103
+ export type SeenPost = { space: string; creatorId?: string | null; hiddenAt?: Date | string | null }
104
+
105
+ /**
106
+ * Whether the viewer sees the post. Everyone of the domain sees every post (decision 2), except a hidden one - its
107
+ * owner and the moderators see that - and the own gallery of someone who left (decision 5).
108
+ */
109
+ export function seesPost(post: SeenPost, viewer: Viewer): boolean {
110
+ const own = !!post.creatorId && post.creatorId === viewer.userId
111
+ if (post.hiddenAt && !own && !viewer.moderator) return false
112
+ if (post.space === 'PERSONAL' && !own && !viewer.activePeople.has(post.creatorId ?? '')) return false
113
+ return true
114
+ }
115
+
116
+ const DAY = 24 * 60 * 60 * 1000
117
+
118
+ /** Whether a photo taken at `takenAt` is near the event - from a day before it starts to a day after it ends. */
119
+ export function nearEvent(event: { startAt: Date | string; endAt: Date | string }, takenAt: Date | string): boolean {
120
+ const at = new Date(takenAt).getTime()
121
+ return new Date(event.startAt).getTime() - DAY <= at && at <= new Date(event.endAt).getTime() + DAY
122
+ }
123
+
124
+ /** The events offered for a photo: those near when it was taken first - the closest start first - then the newest. */
125
+ export function orderChoices<T extends { id: string; startAt: Date | string; endAt: Date | string }>(
126
+ events: T[],
127
+ takenAt: Date | string
128
+ ): (T & { near: boolean })[] {
129
+ const at = new Date(takenAt).getTime()
130
+ const unique = [...new Map(events.map(one => [one.id, one])).values()]
131
+ return unique
132
+ .map(one => ({ ...one, near: nearEvent(one, takenAt) }))
133
+ .sort((a, b) =>
134
+ a.near !== b.near
135
+ ? Number(b.near) - Number(a.near)
136
+ : a.near
137
+ ? Math.abs(new Date(a.startAt).getTime() - at) - Math.abs(new Date(b.startAt).getTime() - at)
138
+ : new Date(b.startAt).getTime() - new Date(a.startAt).getTime()
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Who hears that an event's first photo is up: the people named on it, or everyone of the domain when it names nobody
144
+ * (an event made from the gallery). Never the one who posted it.
145
+ */
146
+ export function firstPhotoReceivers(attendeeIds: string[], memberIds: string[], posterId: string): string[] {
147
+ const base = attendeeIds.length ? attendeeIds : memberIds
148
+ return [...new Set(base)].filter(id => id && id !== posterId)
149
+ }
150
+
151
+ /** The page a notice opens - the gallery, narrowed to the event or the post. */
152
+ export function galleryAddressOf(where: { eventId?: string | null; postId?: string | null }): string {
153
+ if (where.postId) return `gallery-page?post=${where.postId}`
154
+ if (where.eventId) return `gallery-page?event=${where.eventId}`
155
+ return 'gallery-page'
156
+ }
157
+
158
+ export type Say = (key: string, english: string, params?: Record<string, unknown>) => string
159
+
160
+ /** The words of the notice that an event's first photo is up. */
161
+ export function firstPhotoWords(event: { name: string }, poster: string, caption: string | null | undefined, say: Say) {
162
+ const subject = say('gallery.notice.first-photo-subject', 'Photos of {event} are up', { event: event.name })
163
+ const body = say('gallery.notice.first-photo-body', '{poster} posted the first photos of {event}.', {
164
+ poster,
165
+ event: event.name
166
+ })
167
+ return {
168
+ subject,
169
+ summary: body,
170
+ body: caption ? `${body}\n\n${caption}` : body,
171
+ action: say('gallery.notice.action', 'Open the gallery'),
172
+ note: say('gallery.notice.note', 'Open Gallery in the application to see them.')
173
+ }
174
+ }
175
+
176
+ /** The words of the notice that a moderator hid one's post. */
177
+ export function hiddenWords(reason: string, say: Say) {
178
+ const subject = say('gallery.notice.hidden-subject', 'Your gallery post was hidden')
179
+ const body = say('gallery.notice.hidden-body', 'Reason: {reason}', { reason })
180
+ return {
181
+ subject,
182
+ summary: body,
183
+ body,
184
+ action: say('gallery.notice.action', 'Open the gallery'),
185
+ note: say('gallery.notice.note', 'Open Gallery in the application to see them.')
186
+ }
187
+ }
@@ -0,0 +1,254 @@
1
+ /*
2
+ * What the gallery's doors do with the database (ADR-0165 decisions 2-7): who sees a post, its photos and event, which
3
+ * events a person may post to, opening an event's gallery, and the two notices.
4
+ */
5
+ import { EntityManager, In } from 'typeorm'
6
+
7
+ import { Attachment } from '@things-factory/attachment-base'
8
+ import { checkPermission, getDomainAssignees, User, UserStatus } from '@things-factory/auth-base'
9
+ import { Attendee, Event } from '@things-factory/calendar'
10
+ import { translatorFor } from '@things-factory/i18n-base/translator'
11
+ import { noticeLinkOf, sendNoticeWhenCommitted, type Notice } from '@things-factory/notification'
12
+ import { getRepository } from '@things-factory/shell'
13
+
14
+ import { GalleryEvent, GalleryLike, GalleryPost } from './gallery'
15
+ import {
16
+ firstPhotoReceivers,
17
+ firstPhotoWords,
18
+ galleryAddressOf,
19
+ hiddenWords,
20
+ seesPost,
21
+ type Say,
22
+ type Viewer
23
+ } from './gallery-rules'
24
+
25
+ /** The photos of a post hang on it under this `refType` (attachment-base). */
26
+ export const GALLERY_REF_TYPE = 'Gallery Post'
27
+
28
+ const isId = (id?: string | null) => !!id && /^[0-9a-f-]{36}$/i.test(id)
29
+
30
+ async function holds(context: any, category: string, privilege: string, key: string): Promise<boolean> {
31
+ const state = context?.state ?? {}
32
+ const { domain, user, unsafeIP, prohibitedPrivileges } = state
33
+ if (!domain || !user) return false
34
+ /* Asked once per request - a list asks it for each row. */
35
+ state[key] ??= checkPermission(
36
+ { category, privilege, owner: true } as any,
37
+ user,
38
+ domain,
39
+ unsafeIP,
40
+ prohibitedPrivileges
41
+ ).then(Boolean)
42
+ return await state[key]
43
+ }
44
+
45
+ /** Whether the request moderates the gallery - the module's one task, `gallery.moderation`. */
46
+ export const isModerator = (context: any) => holds(context, 'gallery', 'mutation', '__galleryModerator')
47
+
48
+ /** Whether the request reads the domain's whole calendar. */
49
+ const readsCalendar = (context: any) => holds(context, 'calendar', 'query', '__galleryCalendarReader')
50
+
51
+ /** The request's person as a post's viewer - asked once per request. */
52
+ export async function viewerOf(context: any): Promise<Viewer> {
53
+ const state = context.state
54
+ if (!state.__galleryViewer) {
55
+ const people = await getDomainAssignees(state.domain, state.tx)
56
+ state.__galleryViewer = {
57
+ userId: state.user.id,
58
+ moderator: await isModerator(context),
59
+ activePeople: new Set(people.map(one => one.id))
60
+ }
61
+ }
62
+ return state.__galleryViewer
63
+ }
64
+
65
+ /** The post as the request may see it, or nothing - one answer for "none" and "not for you". */
66
+ export async function visiblePost(id: string, context: any, tx?: EntityManager): Promise<GalleryPost | undefined> {
67
+ const { domain, user } = context?.state ?? {}
68
+ if (!isId(id) || !domain || !user) return undefined
69
+ const post = await getRepository(GalleryPost, tx).findOne({ where: { id, domain: { id: domain.id } } as any })
70
+ if (!post) return undefined
71
+ return seesPost(post, await viewerOf(context)) ? post : undefined
72
+ }
73
+
74
+ /** The photos of each post, in the order they were added. */
75
+ export async function photosOf(
76
+ postIds: string[],
77
+ domainId: string,
78
+ tx?: EntityManager
79
+ ): Promise<Map<string, Attachment[]>> {
80
+ const out = new Map<string, Attachment[]>()
81
+ if (!postIds.length) return out
82
+ const rows = await getRepository(Attachment, tx).find({
83
+ where: { domain: { id: domainId }, refType: GALLERY_REF_TYPE, refBy: In(postIds) } as any,
84
+ order: { createdAt: 'ASC' } as any
85
+ })
86
+ for (const one of rows) out.set(one.refBy!, [...(out.get(one.refBy!) ?? []), one])
87
+ return out
88
+ }
89
+
90
+ /** The name, time and place of each event. */
91
+ export async function eventsOf(eventIds: string[], domainId: string, tx?: EntityManager): Promise<Map<string, Event>> {
92
+ const ids = [...new Set(eventIds.filter(isId))]
93
+ if (!ids.length) return new Map()
94
+ const rows = await getRepository(Event, tx).find({ where: { id: In(ids), domain: { id: domainId } } as any })
95
+ return new Map(rows.map(one => [one.id, one]))
96
+ }
97
+
98
+ /** Puts on each post what its fields answer from - who posted it, its photos, its event and whether the request's person likes it. */
99
+ export async function decorate(posts: GalleryPost[], context: any): Promise<GalleryPost[]> {
100
+ const { domain, user, tx } = context.state
101
+ const ids = posts.map(one => one.id)
102
+ const photos = await photosOf(ids, domain.id, tx)
103
+ const events = await eventsOf(
104
+ posts.map(one => one.eventId!),
105
+ domain.id,
106
+ tx
107
+ )
108
+ const liked = ids.length
109
+ ? await getRepository(GalleryLike, tx).find({ where: { post: { id: In(ids) }, user: { id: user.id } } as any })
110
+ : []
111
+ const likedIds = new Set(liked.map(one => one.postId))
112
+ const creatorIds = [...new Set(posts.map(one => one.creatorId).filter(Boolean))]
113
+ const creators = new Map(
114
+ (creatorIds.length ? await getRepository(User, tx).find({ where: { id: In(creatorIds) } as any }) : []).map(one => [
115
+ one.id,
116
+ one
117
+ ])
118
+ )
119
+ for (const one of posts as any[]) {
120
+ one.__creator = creators.get(one.creatorId) ?? null
121
+ one.__photos = photos.get(one.id) ?? []
122
+ one.__event = one.eventId ? (events.get(one.eventId) ?? null) : null
123
+ one.__liked = likedIds.has(one.id)
124
+ }
125
+ return posts
126
+ }
127
+
128
+ /** Whether the event's gallery is open. */
129
+ export async function isOpenEvent(eventId: string, domainId: string, tx?: EntityManager): Promise<boolean> {
130
+ return !!(await getRepository(GalleryEvent, tx).findOne({
131
+ where: { domain: { id: domainId }, event: { id: eventId } } as any
132
+ }))
133
+ }
134
+
135
+ /**
136
+ * Whether the request's person may post to the event: its gallery is open, or they see the event - they are named on
137
+ * it or responsible for it, or they read the whole calendar. An event they cannot see takes no photo (판정 1).
138
+ */
139
+ export async function maySeeEvent(eventId: string, context: any, tx?: EntityManager): Promise<Event | undefined> {
140
+ const { domain, user } = context.state
141
+ if (!isId(eventId)) return undefined
142
+ const event = await getRepository(Event, tx).findOne({ where: { id: eventId, domain: { id: domain.id } } as any })
143
+ if (!event) return undefined
144
+ if (event.organizerId === user.id) return event
145
+ if (await isOpenEvent(eventId, domain.id, tx)) return event
146
+ if (
147
+ await getRepository(Attendee, tx).findOne({
148
+ where: { domain: { id: domain.id }, event: { id: eventId }, user: { id: user.id } } as any
149
+ })
150
+ )
151
+ return event
152
+ return (await readsCalendar(context)) ? event : undefined
153
+ }
154
+
155
+ /** The events the person may post to that overlap [from, to): their own, the open ones, and all when they read the calendar. */
156
+ export async function eventsSeenBetween(context: any, from: Date, to: Date, tx?: EntityManager): Promise<Event[]> {
157
+ const { domain, user } = context.state
158
+ const qb = getRepository(Event, tx)
159
+ .createQueryBuilder('E')
160
+ .where('E.domain = :domain', { domain: domain.id })
161
+ .andWhere('E.startAt < :to AND E.endAt > :from', { from, to })
162
+ if (!(await readsCalendar(context))) {
163
+ const named = await getRepository(Attendee, tx).find({
164
+ where: { domain: { id: domain.id }, user: { id: user.id } } as any
165
+ })
166
+ const open = await getRepository(GalleryEvent, tx).find({ where: { domain: { id: domain.id } } as any })
167
+ const ids = [...new Set([...named.map(one => one.eventId), ...open.map(one => one.eventId)].filter(Boolean))]
168
+ qb.andWhere(ids.length ? '(E.organizer = :me OR E.id IN (:...ids))' : 'E.organizer = :me', { me: user.id, ids })
169
+ }
170
+ return await qb.orderBy('E.startAt', 'DESC').take(50).getMany()
171
+ }
172
+
173
+ /** Opens the event's gallery, once. */
174
+ export async function openEvent(eventId: string, context: any, tx: EntityManager): Promise<void> {
175
+ const { domain, user } = context.state
176
+ if (await isOpenEvent(eventId, domain.id, tx)) return
177
+ await getRepository(GalleryEvent, tx).save({ domain, event: { id: eventId }, creator: user, updater: user } as any)
178
+ }
179
+
180
+ /** The sentences in `lng` from this module's translations, the English one where `lng` has none. */
181
+ const sayIn =
182
+ (lng: string): Say =>
183
+ (key, english, params = {}) =>
184
+ translatorFor(lng)(key, {
185
+ ...params,
186
+ defaultValue: english.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''))
187
+ })
188
+
189
+ /** The people to tell, as notification takes them - only those who can sign in. */
190
+ async function receiversOf(userIds: string[], tx?: EntityManager) {
191
+ if (!userIds.length) return []
192
+ const people = await getRepository(User, tx).find({ where: { id: In(userIds) } as any })
193
+ return people
194
+ .filter(one => one.status === UserStatus.ACTIVATED)
195
+ .map(one => ({ id: one.id, email: one.email, locale: (one as any).locale }))
196
+ }
197
+
198
+ function tell(context: any, notice: Omit<Notice, 'link'>): void {
199
+ sendNoticeWhenCommitted(context, { ...notice, link: noticeLinkOf(context, notice.address) } as Notice)
200
+ }
201
+
202
+ /**
203
+ * When the post is the event's first, tells the people named on the event - everyone of the domain when it names
204
+ * nobody - that its photos are up (decision 6). Once per event: a later post is not the first.
205
+ */
206
+ export async function tellFirstPhoto(context: any, post: GalleryPost, eventId: string): Promise<void> {
207
+ const { domain, user, tx } = context.state
208
+ const others = await getRepository(GalleryPost, tx)
209
+ .createQueryBuilder('P')
210
+ .where('P.domain = :domain AND P.event = :event AND P.id != :id', {
211
+ domain: domain.id,
212
+ event: eventId,
213
+ id: post.id
214
+ })
215
+ .withDeleted()
216
+ .getCount()
217
+ if (others) return
218
+ const event = await getRepository(Event, tx).findOne({ where: { id: eventId } as any })
219
+ if (!event) return
220
+ const attendees = await getRepository(Attendee, tx).find({
221
+ where: { domain: { id: domain.id }, event: { id: eventId } } as any
222
+ })
223
+ const members = attendees.some(one => one.userId) ? [] : (await getDomainAssignees(domain, tx)).map(one => one.id)
224
+ const receivers = await receiversOf(
225
+ firstPhotoReceivers(attendees.map(one => one.userId!).filter(Boolean), members, user.id),
226
+ tx
227
+ )
228
+ if (!receivers.length) return
229
+ tell(context, {
230
+ type: 'gallery',
231
+ receivers,
232
+ words: lng => firstPhotoWords(event, user.name ?? '', post.caption, sayIn(lng)),
233
+ address: galleryAddressOf({ eventId }),
234
+ label: `gallery first-photo ${eventId}`,
235
+ /* Photos are no news for the inbox of another app - the app and push carry it, mail only where the domain turned it on. */
236
+ channels: { email: false }
237
+ })
238
+ }
239
+
240
+ /** Tells the owner that a moderator hid their post, and why (decision 5). */
241
+ export async function tellHidden(context: any, post: GalleryPost, reason: string): Promise<void> {
242
+ if (!post.creatorId || post.creatorId === context.state.user.id) return
243
+ const receivers = await receiversOf([post.creatorId], context.state.tx)
244
+ if (!receivers.length) return
245
+ tell(context, {
246
+ type: 'gallery',
247
+ level: 'warn',
248
+ receivers,
249
+ words: lng => hiddenWords(reason, sayIn(lng)),
250
+ address: galleryAddressOf({ postId: post.id }),
251
+ label: `gallery hidden ${post.id}`,
252
+ channels: { email: false }
253
+ })
254
+ }
@@ -0,0 +1,151 @@
1
+ import { Field, ID, InputType, Int, ObjectType } from 'type-graphql'
2
+
3
+ import { GalleryPost, GallerySpace } from './gallery'
4
+
5
+ @ObjectType({ description: 'One photo of a gallery post.' })
6
+ export class GalleryPhoto {
7
+ @Field(type => ID, { description: 'The attachment id of the photo.' })
8
+ id: string
9
+
10
+ @Field({ nullable: true, description: 'The file name.' })
11
+ name?: string
12
+
13
+ @Field({ nullable: true, description: 'A small picture of it, usable as an image source.' })
14
+ thumbnail?: string
15
+
16
+ @Field({ description: 'Where the photo itself is read.' })
17
+ fullpath: string
18
+
19
+ @Field({ nullable: true, description: 'The image type.' })
20
+ mimetype?: string
21
+ }
22
+
23
+ /** What everyone of the domain sees of an event a post hangs on - name, time and place, never its people (ADR-0165 사용자 판정 1). */
24
+ @ObjectType({ description: 'The name, time and place of a calendar event - what the gallery shows of it.' })
25
+ export class GalleryEventFacts {
26
+ @Field(type => ID, { description: 'The calendar event id.' })
27
+ id: string
28
+
29
+ @Field({ description: 'The event name.' })
30
+ name: string
31
+
32
+ @Field({ description: 'When it starts.' })
33
+ startAt: Date
34
+
35
+ @Field({ description: 'When it ends.' })
36
+ endAt: Date
37
+
38
+ @Field({ nullable: true, description: 'Where it takes place.' })
39
+ location?: string
40
+ }
41
+
42
+ @ObjectType({ description: 'An event whose gallery is open, with how many posts it holds and the photo shown for it.' })
43
+ export class GalleryEventCard extends GalleryEventFacts {
44
+ @Field(type => Int, { description: 'How many posts the viewer sees in it.' })
45
+ postCount: number
46
+
47
+ @Field(type => GalleryPhoto, {
48
+ nullable: true,
49
+ description: 'The photo shown for it - the first of its most liked post.'
50
+ })
51
+ cover?: GalleryPhoto
52
+ }
53
+
54
+ @ObjectType({ description: 'An event offered for a photo.' })
55
+ export class GalleryEventChoice extends GalleryEventFacts {
56
+ @Field({ description: 'Whether it is near when the photo was taken.' })
57
+ near: boolean
58
+ }
59
+
60
+ @ObjectType({ description: 'A tag and how many posts hold it.' })
61
+ export class GalleryTagCount {
62
+ @Field({ description: 'The tag, without the # mark.' })
63
+ tag: string
64
+
65
+ @Field(type => Int, { description: 'How many posts hold it.' })
66
+ count: number
67
+ }
68
+
69
+ @ObjectType({ description: 'A person, as the gallery names them.' })
70
+ export class GalleryPerson {
71
+ @Field(type => ID, { description: 'User id.' })
72
+ id: string
73
+
74
+ @Field({ nullable: true, description: 'Their name.' })
75
+ name?: string
76
+ }
77
+
78
+ @ObjectType({ description: 'A page of gallery posts.' })
79
+ export class GalleryPostList {
80
+ @Field(type => [GalleryPost], { description: 'The posts, newest first.' })
81
+ items: GalleryPost[]
82
+
83
+ @Field(type => Int, { description: 'How many posts the narrowing holds.' })
84
+ total: number
85
+ }
86
+
87
+ @ObjectType({ description: "What the gallery's menu entry says." })
88
+ export class GallerySummary {
89
+ @Field(type => Int, { description: 'Posts by others since the person last opened the gallery.' })
90
+ fresh: number
91
+ }
92
+
93
+ @ObjectType({ description: 'A like as it stands after the change.' })
94
+ export class GalleryLikeState {
95
+ @Field(type => ID, { description: 'Post id.' })
96
+ id: string
97
+
98
+ @Field(type => Int, { description: 'How many people like it.' })
99
+ likeCount: number
100
+
101
+ @Field({ description: 'Whether the person likes it.' })
102
+ liked: boolean
103
+ }
104
+
105
+ @InputType({ description: 'What a gallery post holds besides its photos.' })
106
+ export class GalleryPostInput {
107
+ @Field(type => GallerySpace, { nullable: true, description: "The company's gallery (default) or the person's own." })
108
+ space?: GallerySpace
109
+
110
+ @Field({ nullable: true, description: 'A short caption, plain text.' })
111
+ caption?: string
112
+
113
+ @Field({ nullable: true, description: 'When the photos were taken; now when not given.' })
114
+ takenAt?: Date
115
+
116
+ @Field(type => [String], { nullable: true, description: 'The tags; a leading # is dropped.' })
117
+ tags?: string[]
118
+
119
+ @Field({ nullable: true, description: 'The calendar event the photos are of - only in the company gallery.' })
120
+ eventId?: string
121
+ }
122
+
123
+ @InputType({ description: 'How a list of gallery posts is narrowed.' })
124
+ export class GalleryFilter {
125
+ @Field(type => GallerySpace, { nullable: true, description: 'Only this space.' })
126
+ space?: GallerySpace
127
+
128
+ @Field({ nullable: true, description: 'Only the posts of this event.' })
129
+ eventId?: string
130
+
131
+ @Field({ nullable: true, description: 'Only the posts holding this tag.' })
132
+ tag?: string
133
+
134
+ @Field({ nullable: true, description: "Only this person's posts." })
135
+ authorId?: string
136
+ }
137
+
138
+ @InputType({ description: 'An event made from the gallery.' })
139
+ export class GalleryEventInput {
140
+ @Field({ description: 'The event name.' })
141
+ name: string
142
+
143
+ @Field({ description: 'When it starts.' })
144
+ startAt: Date
145
+
146
+ @Field({ description: 'When it ends.' })
147
+ endAt: Date
148
+
149
+ @Field({ nullable: true, description: 'Where it takes place.' })
150
+ location?: string
151
+ }