@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.
- package/client/bootstrap.ts +16 -0
- package/client/components/gallery-forms.ts +188 -0
- package/client/components/gallery-uploader.ts +557 -0
- package/client/components/gallery-viewer.ts +397 -0
- package/client/index.ts +1 -0
- package/client/pages/gallery-page.ts +567 -0
- package/client/route.ts +8 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/gallery-api.ts +157 -0
- package/client/utils/gallery-badge.ts +44 -0
- package/client/utils/gallery-words.ts +22 -0
- package/client/utils/photo-prep.ts +93 -0
- package/dist-client/bootstrap.d.ts +1 -0
- package/dist-client/bootstrap.js +16 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/components/gallery-forms.d.ts +31 -0
- package/dist-client/components/gallery-forms.js +223 -0
- package/dist-client/components/gallery-forms.js.map +1 -0
- package/dist-client/components/gallery-uploader.d.ts +51 -0
- package/dist-client/components/gallery-uploader.js +588 -0
- package/dist-client/components/gallery-uploader.js.map +1 -0
- package/dist-client/components/gallery-viewer.d.ts +30 -0
- package/dist-client/components/gallery-viewer.js +416 -0
- package/dist-client/components/gallery-viewer.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +2 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/pages/gallery-page.d.ts +55 -0
- package/dist-client/pages/gallery-page.js +586 -0
- package/dist-client/pages/gallery-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +9 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/gallery-api.d.ts +95 -0
- package/dist-client/utils/gallery-api.js +46 -0
- package/dist-client/utils/gallery-api.js.map +1 -0
- package/dist-client/utils/gallery-badge.d.ts +4 -0
- package/dist-client/utils/gallery-badge.js +45 -0
- package/dist-client/utils/gallery-badge.js.map +1 -0
- package/dist-client/utils/gallery-words.d.ts +7 -0
- package/dist-client/utils/gallery-words.js +20 -0
- package/dist-client/utils/gallery-words.js.map +1 -0
- package/dist-client/utils/photo-prep.d.ts +10 -0
- package/dist-client/utils/photo-prep.js +102 -0
- package/dist-client/utils/photo-prep.js.map +1 -0
- package/dist-server/attachment-reader.d.ts +1 -0
- package/dist-server/attachment-reader.js +13 -0
- package/dist-server/attachment-reader.js.map +1 -0
- package/dist-server/index.d.ts +1 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/role-tasks.d.ts +2 -0
- package/dist-server/role-tasks.js +15 -0
- package/dist-server/role-tasks.js.map +1 -0
- package/dist-server/service/gallery/gallery-resolver.d.ts +29 -0
- package/dist-server/service/gallery/gallery-resolver.js +602 -0
- package/dist-server/service/gallery/gallery-resolver.js.map +1 -0
- package/dist-server/service/gallery/gallery-rules.d.ts +88 -0
- package/dist-server/service/gallery/gallery-rules.js +181 -0
- package/dist-server/service/gallery/gallery-rules.js.map +1 -0
- package/dist-server/service/gallery/gallery-service.d.ts +37 -0
- package/dist-server/service/gallery/gallery-service.js +234 -0
- package/dist-server/service/gallery/gallery-service.js.map +1 -0
- package/dist-server/service/gallery/gallery-types.d.ts +62 -0
- package/dist-server/service/gallery/gallery-types.js +227 -0
- package/dist-server/service/gallery/gallery-types.js.map +1 -0
- package/dist-server/service/gallery/gallery.d.ts +83 -0
- package/dist-server/service/gallery/gallery.js +330 -0
- package/dist-server/service/gallery/gallery.js.map +1 -0
- package/dist-server/service/gallery/index.d.ts +4 -0
- package/dist-server/service/gallery/index.js +8 -0
- package/dist-server/service/gallery/index.js.map +1 -0
- package/dist-server/service/index.d.ts +7 -0
- package/dist-server/service/index.js +23 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +48 -4
- package/server/attachment-reader.ts +14 -0
- package/server/index.ts +9 -0
- package/server/role-tasks.ts +14 -0
- package/server/service/gallery/gallery-resolver.ts +541 -0
- package/server/service/gallery/gallery-rules.ts +187 -0
- package/server/service/gallery/gallery-service.ts +254 -0
- package/server/service/gallery/gallery-types.ts +151 -0
- package/server/service/gallery/gallery.ts +266 -0
- package/server/service/gallery/index.ts +5 -0
- package/server/service/index.ts +19 -0
- package/server/tsconfig.json +11 -0
- package/test/gallery-persistence.test.ts +372 -0
- package/test/gallery-rules.test.ts +140 -0
- package/test/open-doors.test.ts +62 -0
- package/test/photo-prep.test.ts +61 -0
- package/test/translations.test.ts +68 -0
- package/things-factory.config.js +8 -0
- package/translations/en.json +91 -0
- package/translations/es.json +91 -0
- package/translations/ja.json +91 -0
- package/translations/ko.json +91 -0
- package/translations/ms.json +91 -0
- package/translations/zh.json +91 -0
- 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
|
+
}
|