@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,266 @@
1
+ /*
2
+ * The tables of the gallery (ADR-0165 decisions 2, 4-6): a post of photos, a like, an event its gallery is open for,
3
+ * and when a person last looked. The photos themselves are attachments hung on the post (`refType: 'Gallery Post'`);
4
+ * an event is calendar's.
5
+ */
6
+ import {
7
+ Column,
8
+ CreateDateColumn,
9
+ DeleteDateColumn,
10
+ Entity,
11
+ Index,
12
+ ManyToOne,
13
+ PrimaryGeneratedColumn,
14
+ RelationId,
15
+ UpdateDateColumn
16
+ } from 'typeorm'
17
+ import { Field, ID, Int, ObjectType, registerEnumType } from 'type-graphql'
18
+
19
+ import { User } from '@things-factory/auth-base'
20
+ import { Event } from '@things-factory/calendar'
21
+ import { Domain } from '@things-factory/shell'
22
+
23
+ /** Where a post lives. Both are seen by everyone of the domain - the space says only whose it is and where it shows. */
24
+ export enum GallerySpace {
25
+ /** The company's - events, workshops and work. */
26
+ COMPANY = 'COMPANY',
27
+ /** The person's own - their days, hobbies and pets. */
28
+ PERSONAL = 'PERSONAL'
29
+ }
30
+
31
+ registerEnumType(GallerySpace, {
32
+ name: 'GallerySpace',
33
+ description: "Where a gallery post lives: the company's gallery or the person's own."
34
+ })
35
+
36
+ @Entity()
37
+ @Index('ix_gallery_post_0', (one: GalleryPost) => [one.domain, one.createdAt])
38
+ @Index('ix_gallery_post_1', (one: GalleryPost) => [one.domain, one.event])
39
+ @Index('ix_gallery_post_2', (one: GalleryPost) => [one.domain, one.creator])
40
+ @ObjectType({
41
+ description: 'A post of the gallery - one or more photos, a short caption and tags, seen by everyone of the domain.'
42
+ })
43
+ export class GalleryPost {
44
+ @PrimaryGeneratedColumn('uuid')
45
+ @Field(type => ID, { description: 'Post id.' })
46
+ readonly id: string
47
+
48
+ @ManyToOne(type => Domain)
49
+ @Field(type => Domain, { nullable: true, description: 'The domain the post is in.' })
50
+ domain?: Domain
51
+
52
+ @RelationId((one: GalleryPost) => one.domain)
53
+ domainId?: string
54
+
55
+ @Column({ default: GallerySpace.COMPANY })
56
+ @Field(type => GallerySpace, { description: "The company's gallery or the person's own." })
57
+ space: GallerySpace
58
+
59
+ @Column({ type: 'text', nullable: true })
60
+ @Field({ nullable: true, description: 'A short caption, plain text.' })
61
+ caption?: string
62
+
63
+ @Column()
64
+ @Field({ description: 'When the photos were taken - from the first photo, or when it was posted.' })
65
+ takenAt: Date
66
+
67
+ @Column('simple-json', { nullable: true })
68
+ @Field(type => [String], { nullable: true, description: 'The tags, without the # mark.' })
69
+ tags?: string[]
70
+
71
+ /* Read as its name, time and place (GalleryEventFacts) - the post's field never hands out the calendar's event. */
72
+ @ManyToOne(type => Event, { nullable: true })
73
+ event?: Event
74
+
75
+ @RelationId((one: GalleryPost) => one.event)
76
+ eventId?: string
77
+
78
+ @Column('int', { default: 0 })
79
+ @Field(type => Int, { description: 'How many people like it.' })
80
+ likeCount: number
81
+
82
+ @Column({ nullable: true })
83
+ @Field({ nullable: true, description: 'When a moderator hid it; hidden, only its owner and the moderators see it.' })
84
+ hiddenAt?: Date
85
+
86
+ @Column({ nullable: true })
87
+ hiddenReason?: string
88
+
89
+ @ManyToOne(type => User, { nullable: true })
90
+ hiddenBy?: User
91
+
92
+ @RelationId((one: GalleryPost) => one.hiddenBy)
93
+ hiddenById?: string
94
+
95
+ @CreateDateColumn()
96
+ @Field({ nullable: true, description: 'When it was posted.' })
97
+ createdAt?: Date
98
+
99
+ @UpdateDateColumn()
100
+ @Field({ nullable: true, description: 'When it last changed.' })
101
+ updatedAt?: Date
102
+
103
+ @DeleteDateColumn()
104
+ @Field({ nullable: true, description: 'When its owner deleted it.' })
105
+ deletedAt?: Date
106
+
107
+ @ManyToOne(type => User, { nullable: true })
108
+ @Field(type => User, { nullable: true, description: 'Who posted it - the owner.' })
109
+ creator?: User
110
+
111
+ @RelationId((one: GalleryPost) => one.creator)
112
+ creatorId?: string
113
+
114
+ @ManyToOne(type => User, { nullable: true })
115
+ @Field(type => User, { nullable: true, description: 'Who changed it last.' })
116
+ updater?: User
117
+
118
+ @RelationId((one: GalleryPost) => one.updater)
119
+ updaterId?: string
120
+ }
121
+
122
+ @Entity()
123
+ @Index('ix_gallery_like_0', (one: GalleryLike) => [one.post, one.user], { unique: true, where: '"deleted_at" IS NULL' })
124
+ @ObjectType({ description: 'One person liking one gallery post.' })
125
+ export class GalleryLike {
126
+ @PrimaryGeneratedColumn('uuid')
127
+ @Field(type => ID, { description: 'Like id.' })
128
+ readonly id: string
129
+
130
+ @ManyToOne(type => Domain)
131
+ domain?: Domain
132
+
133
+ @RelationId((one: GalleryLike) => one.domain)
134
+ domainId?: string
135
+
136
+ @ManyToOne(type => GalleryPost, { onDelete: 'CASCADE' })
137
+ post?: GalleryPost
138
+
139
+ @RelationId((one: GalleryLike) => one.post)
140
+ postId?: string
141
+
142
+ @ManyToOne(type => User)
143
+ @Field(type => User, { nullable: true, description: 'Who likes it.' })
144
+ user?: User
145
+
146
+ @RelationId((one: GalleryLike) => one.user)
147
+ userId?: string
148
+
149
+ @CreateDateColumn()
150
+ @Field({ nullable: true, description: 'When they liked it.' })
151
+ createdAt?: Date
152
+
153
+ @UpdateDateColumn()
154
+ updatedAt?: Date
155
+
156
+ @DeleteDateColumn()
157
+ deletedAt?: Date
158
+
159
+ @ManyToOne(type => User, { nullable: true })
160
+ creator?: User
161
+
162
+ @RelationId((one: GalleryLike) => one.creator)
163
+ creatorId?: string
164
+
165
+ @ManyToOne(type => User, { nullable: true })
166
+ updater?: User
167
+
168
+ @RelationId((one: GalleryLike) => one.updater)
169
+ updaterId?: string
170
+ }
171
+
172
+ /**
173
+ * A calendar event whose gallery is open - opened by a moderator making the event from the gallery, or by the first post
174
+ * linked to it. Everyone of the domain may post to an open event (ADR-0165 사용자 판정 1).
175
+ */
176
+ @Entity()
177
+ @Index('ix_gallery_event_0', (one: GalleryEvent) => [one.domain, one.event], {
178
+ unique: true,
179
+ where: '"deleted_at" IS NULL'
180
+ })
181
+ @ObjectType({ description: 'A calendar event whose gallery is open to everyone of the domain.' })
182
+ export class GalleryEvent {
183
+ @PrimaryGeneratedColumn('uuid')
184
+ @Field(type => ID, { description: 'Gallery event id.' })
185
+ readonly id: string
186
+
187
+ @ManyToOne(type => Domain)
188
+ domain?: Domain
189
+
190
+ @RelationId((one: GalleryEvent) => one.domain)
191
+ domainId?: string
192
+
193
+ @ManyToOne(type => Event, { onDelete: 'CASCADE' })
194
+ event?: Event
195
+
196
+ @RelationId((one: GalleryEvent) => one.event)
197
+ eventId?: string
198
+
199
+ @CreateDateColumn()
200
+ createdAt?: Date
201
+
202
+ @UpdateDateColumn()
203
+ updatedAt?: Date
204
+
205
+ @DeleteDateColumn()
206
+ deletedAt?: Date
207
+
208
+ @ManyToOne(type => User, { nullable: true })
209
+ creator?: User
210
+
211
+ @RelationId((one: GalleryEvent) => one.creator)
212
+ creatorId?: string
213
+
214
+ @ManyToOne(type => User, { nullable: true })
215
+ updater?: User
216
+
217
+ @RelationId((one: GalleryEvent) => one.updater)
218
+ updaterId?: string
219
+ }
220
+
221
+ /** When a person last opened the gallery - the menu counts the posts that came after. */
222
+ @Entity()
223
+ @Index('ix_gallery_visit_0', (one: GalleryVisit) => [one.domain, one.user], {
224
+ unique: true,
225
+ where: '"deleted_at" IS NULL'
226
+ })
227
+ export class GalleryVisit {
228
+ @PrimaryGeneratedColumn('uuid')
229
+ readonly id: string
230
+
231
+ @ManyToOne(type => Domain)
232
+ domain?: Domain
233
+
234
+ @RelationId((one: GalleryVisit) => one.domain)
235
+ domainId?: string
236
+
237
+ @ManyToOne(type => User)
238
+ user?: User
239
+
240
+ @RelationId((one: GalleryVisit) => one.user)
241
+ userId?: string
242
+
243
+ @Column()
244
+ seenAt: Date
245
+
246
+ @CreateDateColumn()
247
+ createdAt?: Date
248
+
249
+ @UpdateDateColumn()
250
+ updatedAt?: Date
251
+
252
+ @DeleteDateColumn()
253
+ deletedAt?: Date
254
+
255
+ @ManyToOne(type => User, { nullable: true })
256
+ creator?: User
257
+
258
+ @RelationId((one: GalleryVisit) => one.creator)
259
+ creatorId?: string
260
+
261
+ @ManyToOne(type => User, { nullable: true })
262
+ updater?: User
263
+
264
+ @RelationId((one: GalleryVisit) => one.updater)
265
+ updaterId?: string
266
+ }
@@ -0,0 +1,5 @@
1
+ import { GalleryEvent, GalleryLike, GalleryPost, GalleryVisit } from './gallery'
2
+ import { GalleryResolver } from './gallery-resolver'
3
+
4
+ export const entities = [GalleryPost, GalleryLike, GalleryEvent, GalleryVisit]
5
+ export const resolvers = [GalleryResolver]
@@ -0,0 +1,19 @@
1
+ /* EXPORT ENTITY TYPES */
2
+ export * from './gallery/gallery'
3
+ export * from './gallery/gallery-rules'
4
+ export { GALLERY_REF_TYPE, visiblePost } from './gallery/gallery-service'
5
+
6
+ /* IMPORT ENTITIES AND RESOLVERS */
7
+ import { entities as GalleryEntities, resolvers as GalleryResolvers } from './gallery'
8
+
9
+ export const entities = [
10
+ /* ENTITIES */
11
+ ...GalleryEntities
12
+ ]
13
+
14
+ export const schema = {
15
+ resolverClasses: [
16
+ /* RESOLVER CLASSES */
17
+ ...GalleryResolvers
18
+ ]
19
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "strict": false,
5
+ "module": "commonjs",
6
+ "outDir": "../dist-server",
7
+ "baseUrl": "./"
8
+ },
9
+ "include": ["./**/*"],
10
+ "exclude": ["**/*.spec.ts", "**/*.test.ts"]
11
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * The gallery's doors on sqlite with the real entities (ADR-0165): who sees a post, what a post may hang on, the first
3
+ * photo of an event told once, likes, tags, the owner's changes, a moderator hiding a post, and the menu's count.
4
+ *
5
+ * Reads the built server (`dist-server`) - type-graphql needs tsc's decorator metadata. Storing a photo is
6
+ * attachment-base's (its own tests); here a photo is a row.
7
+ */
8
+ import 'reflect-metadata'
9
+
10
+ const { existsSync } = require('fs')
11
+ const { join } = require('path')
12
+ const { DataSource } = require('typeorm')
13
+
14
+ /* What notification was asked to send (ADR-0164) - the channels themselves are notification's tests. */
15
+ const told: any[] = []
16
+ jest.mock('@things-factory/notification', () => ({
17
+ sendNoticeWhenCommitted: (_context: any, notice: any) => told.push(notice),
18
+ noticeLinkOf: () => undefined
19
+ }))
20
+
21
+ jest.mock('@things-factory/attachment-base', () => {
22
+ const actual = jest.requireActual('@things-factory/attachment-base')
23
+ const shell = jest.requireActual('@things-factory/shell')
24
+ const repository = (context: any) => shell.getRepository(actual.Attachment, context.state.tx)
25
+ return {
26
+ ...actual,
27
+ createAttachment: async (_: any, { attachment }: any, context: any) => {
28
+ const file = await attachment.file
29
+ return repository(context).save({
30
+ domain: context.state.domain,
31
+ name: file.filename,
32
+ mimetype: file.mimetype,
33
+ encoding: '7bit',
34
+ refType: attachment.refType,
35
+ refBy: attachment.refBy,
36
+ path: `${attachment.refBy}/${file.filename}`,
37
+ size: '1',
38
+ category: 'image'
39
+ })
40
+ },
41
+ deleteAttachment: async (_: any, { id }: any, context: any) => !!(await repository(context).delete(id)),
42
+ deleteAttachmentsByRef: async (_: any, { refBys, refType }: any, context: any) => {
43
+ for (const refBy of refBys) await repository(context).delete({ refBy, refType })
44
+ return true
45
+ }
46
+ }
47
+ })
48
+
49
+ const shell = require('@things-factory/shell')
50
+ const authBase = require('@things-factory/auth-base')
51
+ const attachmentBase = require('@things-factory/attachment-base')
52
+ const calendar = require('@things-factory/calendar')
53
+
54
+ function loadCompiled(modulePath: string): any {
55
+ const resolved = join(__dirname, '..', 'dist-server', `${modulePath}.js`)
56
+ if (!existsSync(resolved))
57
+ throw new Error(`gallery build output is missing: ${resolved}\nBuild it first: npm run build:server`)
58
+ return require(resolved)
59
+ }
60
+
61
+ const { GalleryResolver } = loadCompiled('service/gallery/gallery-resolver')
62
+ const { GalleryPost, GalleryLike, GalleryEvent, GalleryVisit } = loadCompiled('service/gallery/gallery')
63
+
64
+ let dataSource: any
65
+ let domain: any
66
+ let people: Record<string, any>
67
+ const doors = new GalleryResolver()
68
+
69
+ beforeAll(async () => {
70
+ dataSource = new DataSource({
71
+ type: 'sqlite',
72
+ database: ':memory:',
73
+ synchronize: true,
74
+ dropSchema: true,
75
+ namingStrategy: new shell.NamingStrategy(),
76
+ entities: [
77
+ ...shell.entities,
78
+ ...authBase.entities,
79
+ ...attachmentBase.entities,
80
+ ...calendar.entities,
81
+ GalleryPost,
82
+ GalleryLike,
83
+ GalleryEvent,
84
+ GalleryVisit
85
+ ],
86
+ logging: false
87
+ })
88
+ await dataSource.initialize()
89
+ shell.addDataSource('default', dataSource)
90
+ shell.addDataSource('tx', dataSource)
91
+ })
92
+
93
+ afterAll(async () => {
94
+ if (!dataSource) return
95
+ shell.removeDataSource('default')
96
+ shell.removeDataSource('tx')
97
+ await dataSource.destroy()
98
+ })
99
+
100
+ /* mod - moderates · kim, lee - employees · left - an account no longer active · outsider - another domain's. */
101
+ beforeEach(async () => {
102
+ told.length = 0
103
+ await dataSource.synchronize(true)
104
+ domain = await dataSource.getRepository(shell.Domain).save({ name: 'gallery', subdomain: 'gallery' })
105
+ const other = await dataSource.getRepository(shell.Domain).save({ name: 'elsewhere', subdomain: 'elsewhere' })
106
+ const user = (name: string, domains: any[], status = 'activated') =>
107
+ dataSource.getRepository(authBase.User).save({ name, email: `${name}@gallery.test`, status, domains })
108
+ people = {
109
+ mod: await user('mod', [domain]),
110
+ kim: await user('kim', [domain]),
111
+ lee: await user('lee', [domain]),
112
+ left: await user('left', [domain]),
113
+ outsider: await user('outsider', [other])
114
+ }
115
+ })
116
+
117
+ /** A request of `who` - inside the transaction a door opens; moderating and reading the calendar as given. */
118
+ const as = (who: string, { moderator = false, calendarReader = false } = {}) => ({
119
+ state: {
120
+ domain,
121
+ user: people[who],
122
+ tx: dataSource.manager,
123
+ __galleryModerator: Promise.resolve(moderator),
124
+ __galleryCalendarReader: Promise.resolve(calendarReader)
125
+ }
126
+ })
127
+ const photo = (filename = 'a.jpg', mimetype = 'image/jpeg') =>
128
+ Promise.resolve({ filename, mimetype, encoding: '7bit', createReadStream: () => null })
129
+ const post = (who: string, input: any = {}, photos = [photo()]) =>
130
+ doors.createGalleryPost({ space: 'COMPANY', ...input }, photos, as(who))
131
+ const listed = async (who: string, filter: any = {}, opts: any = {}) =>
132
+ (await doors.galleryPosts(filter, 0, 60, as(who, opts))).items.map((one: any) => one.caption)
133
+
134
+ async function eventOf(name: string, attendees: string[] = [], organizer = 'mod') {
135
+ return dataSource.transaction((tx: any) =>
136
+ calendar.createEvent(tx, domain, people[organizer], {
137
+ name,
138
+ startAt: new Date('2026-10-24T09:00:00Z'),
139
+ endAt: new Date('2026-10-25T18:00:00Z'),
140
+ attendees: attendees.map(who => ({ userId: people[who].id }))
141
+ })
142
+ )
143
+ }
144
+
145
+ async function refusal(promise: Promise<unknown>): Promise<string | undefined> {
146
+ try {
147
+ await promise
148
+ } catch (error: any) {
149
+ return error.extensions?.code
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ describe('who sees a post', () => {
155
+ it('★ everyone of the domain sees every post, the newest first', async () => {
156
+ await post('kim', { caption: '첫 사진' })
157
+ await post('lee', { caption: '내 고양이', space: 'PERSONAL' })
158
+ expect(await listed('mod')).toEqual(['내 고양이', '첫 사진'])
159
+ expect(await listed('kim', { space: 'COMPANY' })).toEqual(['첫 사진'])
160
+ expect(await listed('kim', { authorId: people.lee.id })).toEqual(['내 고양이'])
161
+ const [first] = (await doors.galleryPosts({}, 0, 1, as('mod'))).items
162
+ expect((await doors.creator(first))?.name).toBe('lee')
163
+ })
164
+
165
+ it('★ the own gallery of someone who left goes from view; their company posts stay', async () => {
166
+ await post('left', { caption: '회사 사진' })
167
+ await post('left', { caption: '내 사진', space: 'PERSONAL' })
168
+ await dataSource.getRepository(authBase.User).update(people.left.id, { status: 'inactive' })
169
+ expect(await listed('kim')).toEqual(['회사 사진'])
170
+ })
171
+
172
+ it('★ a hidden post is seen by its owner and the moderators only, and its reason by them only', async () => {
173
+ const one = await post('kim', { caption: '가릴 사진' })
174
+ await doors.hideGalleryPost(one.id, '얼굴이 나온 분이 원치 않습니다', as('mod', { moderator: true }))
175
+ expect(await listed('lee')).toEqual([])
176
+ expect(await doors.galleryPost(one.id, as('lee'))).toBeUndefined()
177
+ expect(await listed('kim')).toEqual(['가릴 사진'])
178
+ expect(await listed('mod', {}, { moderator: true })).toEqual(['가릴 사진'])
179
+ const seen = await doors.galleryPost(one.id, as('kim'))
180
+ expect(await doors.hiddenReason(seen, as('kim'))).toBe('얼굴이 나온 분이 원치 않습니다')
181
+ await doors.unhideGalleryPost(one.id, as('mod', { moderator: true }))
182
+ expect(await listed('lee')).toEqual(['가릴 사진'])
183
+ })
184
+
185
+ it('★ the owner is told why their post was hidden; a moderator hiding their own is not', async () => {
186
+ const one = await post('kim')
187
+ await doors.hideGalleryPost(one.id, '중복', as('mod', { moderator: true }))
188
+ expect(told.map(one => [one.label, one.receivers.map((r: any) => r.id)])).toEqual([
189
+ [`gallery hidden ${one.id}`, [people.kim.id]]
190
+ ])
191
+ expect(told[0].words('en').body).toBe('Reason: 중복')
192
+ expect(await refusal(doors.hideGalleryPost(one.id, ' ', as('mod', { moderator: true })))).toBe(
193
+ 'gallery.reason-required'
194
+ )
195
+ })
196
+
197
+ it('a post of another domain is no post', async () => {
198
+ const one = await post('kim')
199
+ const outsider = { state: { ...as('outsider').state, domain: { id: 'elsewhere' } } }
200
+ expect(await doors.galleryPost(one.id, outsider)).toBeUndefined()
201
+ })
202
+ })
203
+
204
+ describe('what a post holds', () => {
205
+ it('★ photos only, 1 to 20, and no event in a person’s own gallery', async () => {
206
+ expect(await refusal(post('kim', {}, []))).toBe('gallery.no-photo')
207
+ expect(await refusal(post('kim', {}, [photo('a.pdf', 'application/pdf')]))).toBe('gallery.not-a-photo')
208
+ expect(
209
+ await refusal(
210
+ post(
211
+ 'kim',
212
+ {},
213
+ Array.from({ length: 21 }, () => photo())
214
+ )
215
+ )
216
+ ).toBe('gallery.too-many-photos')
217
+ const event = await eventOf('가을 워크숍', ['kim'])
218
+ expect(await refusal(post('kim', { space: 'PERSONAL', eventId: event.id }))).toBe('gallery.event-in-personal')
219
+ expect(await dataSource.getRepository(GalleryPost).count()).toBe(0)
220
+ })
221
+
222
+ it('keeps the tags as they are kept and the photos in their order', async () => {
223
+ const one = await post('kim', { caption: ' 가을 ', tags: ['#워크숍', '워크숍', ' 팀 '] }, [
224
+ photo('1.jpg'),
225
+ photo('2.jpg')
226
+ ])
227
+ expect(one).toMatchObject({ caption: '가을', tags: ['워크숍', '팀'], likeCount: 0 })
228
+ expect((await doors.photos(one, as('kim'))).map((p: any) => p.name)).toEqual(['1.jpg', '2.jpg'])
229
+ })
230
+ })
231
+
232
+ describe('an event and its gallery', () => {
233
+ it('★ posts only to an event one sees - and that post opens it to everyone', async () => {
234
+ const event = await eventOf('가을 워크숍', ['kim'])
235
+ expect(await refusal(post('lee', { eventId: event.id }))).toBe('gallery.unknown-event')
236
+ expect(
237
+ (await doors.galleryEventChoices(new Date('2026-10-24T12:00:00Z'), as('lee'))).map((one: any) => one.id)
238
+ ).toEqual([])
239
+
240
+ await post('kim', { eventId: event.id, caption: '김의 사진' })
241
+ expect(await dataSource.getRepository(GalleryEvent).count()).toBe(1)
242
+ expect(
243
+ (await doors.galleryEventChoices(new Date('2026-10-24T12:00:00Z'), as('lee'))).map((one: any) => [
244
+ one.name,
245
+ one.near
246
+ ])
247
+ ).toEqual([['가을 워크숍', true]])
248
+ await post('lee', { eventId: event.id, caption: '이의 사진' })
249
+ expect(await listed('mod', { eventId: event.id })).toEqual(['이의 사진', '김의 사진'])
250
+ })
251
+
252
+ it('★ a calendar reader may post to any event', async () => {
253
+ const event = await eventOf('이사회', [])
254
+ expect(
255
+ await refusal(doors.createGalleryPost({ eventId: event.id }, [photo()], as('lee', { calendarReader: true })))
256
+ ).toBeUndefined()
257
+ })
258
+
259
+ it('★ the first photo is told once - to the people named on it, never the poster', async () => {
260
+ const event = await eventOf('가을 워크숍', ['kim', 'lee'])
261
+ await post('kim', { eventId: event.id, caption: '단풍' })
262
+ await post('lee', { eventId: event.id })
263
+ expect(told).toHaveLength(1)
264
+ expect(told[0].receivers.map((r: any) => r.id)).toEqual([people.lee.id])
265
+ expect(told[0].address).toBe(`gallery-page?event=${event.id}`)
266
+ expect(told[0].words('en')).toMatchObject({
267
+ subject: 'Photos of 가을 워크숍 are up',
268
+ body: 'kim posted the first photos of 가을 워크숍.\n\n단풍'
269
+ })
270
+ })
271
+
272
+ it('★ an event made from the gallery names nobody - its first photo is told to everyone of the domain who is active', async () => {
273
+ const made = await doors.createGalleryEvent(
274
+ { name: '송년회', startAt: new Date('2026-12-20T10:00:00Z'), endAt: new Date('2026-12-20T13:00:00Z') },
275
+ as('mod', { moderator: true })
276
+ )
277
+ await dataSource.getRepository(authBase.User).update(people.left.id, { status: 'inactive' })
278
+ await post('kim', { eventId: made.id })
279
+ expect(told[0].receivers.map((r: any) => r.id).sort()).toEqual([people.mod.id, people.lee.id].sort())
280
+ })
281
+
282
+ it('lists the open events with their posts counted and the most liked post’s first photo shown', async () => {
283
+ const event = await eventOf('가을 워크숍', ['kim', 'lee'])
284
+ await post('kim', { eventId: event.id }, [photo('k1.jpg'), photo('k2.jpg')])
285
+ const liked = await post('lee', { eventId: event.id }, [photo('l1.jpg')])
286
+ await doors.likeGalleryPost(liked.id, true, as('kim'))
287
+ const [card] = await doors.galleryEvents(as('mod'))
288
+ expect(card).toMatchObject({ name: '가을 워크숍', postCount: 2 })
289
+ expect(card.cover.name).toBe('l1.jpg')
290
+ })
291
+ })
292
+
293
+ describe('likes', () => {
294
+ it('★ a like counts once, and is taken back', async () => {
295
+ const one = await post('kim')
296
+ expect(await doors.likeGalleryPost(one.id, true, as('lee'))).toMatchObject({ likeCount: 1, liked: true })
297
+ expect(await doors.likeGalleryPost(one.id, true, as('lee'))).toMatchObject({ likeCount: 1 })
298
+ await doors.likeGalleryPost(one.id, true, as('mod'))
299
+ expect((await doors.galleryLikers(one.id, as('kim'))).map((p: any) => p.name).sort()).toEqual(['lee', 'mod'])
300
+ expect(await doors.likeGalleryPost(one.id, false, as('lee'))).toMatchObject({ likeCount: 1, liked: false })
301
+ expect((await doors.galleryPost(one.id, as('mod'))).__liked).toBe(true)
302
+ expect((await doors.galleryPost(one.id, as('lee'))).__liked).toBe(false)
303
+ expect(told).toHaveLength(0)
304
+ })
305
+ })
306
+
307
+ describe('tags', () => {
308
+ it('★ finds a post by a whole tag, whatever its case', async () => {
309
+ await post('kim', { caption: 'a', tags: ['Workshop'] })
310
+ await post('kim', { caption: 'b', tags: ['Workshop2026'] })
311
+ expect(await listed('lee', { tag: '#workshop' })).toEqual(['a'])
312
+ })
313
+
314
+ it('★ a moderator renames a tag on every post, merging it into one already there', async () => {
315
+ await post('kim', { caption: 'a', tags: ['워크숍2026', '팀'] })
316
+ await post('lee', { caption: 'b', tags: ['2026워크숍'] })
317
+ expect(await doors.renameGalleryTag('워크숍2026', '#2026워크숍', as('mod', { moderator: true }))).toBe(1)
318
+ expect(await doors.galleryTags(undefined, as('kim'))).toEqual([
319
+ { tag: '2026워크숍', count: 2 },
320
+ { tag: '팀', count: 1 }
321
+ ])
322
+ })
323
+ })
324
+
325
+ describe("one's own post", () => {
326
+ it('★ only its owner changes or deletes it, and at least one photo stays', async () => {
327
+ const one = await post('kim', { caption: '처음' }, [photo('1.jpg'), photo('2.jpg')])
328
+ expect(await refusal(doors.updateGalleryPost(one.id, { caption: '남이' }, [], [], as('lee')))).toBe(
329
+ 'gallery.not-yours'
330
+ )
331
+ expect(await refusal(doors.deleteGalleryPost(one.id, as('mod', { moderator: true })))).toBe('gallery.not-yours')
332
+ const photos = await doors.photos(one, as('kim'))
333
+ expect(
334
+ await refusal(
335
+ doors.updateGalleryPost(
336
+ one.id,
337
+ {},
338
+ [],
339
+ photos.map((p: any) => p.id),
340
+ as('kim')
341
+ )
342
+ )
343
+ ).toBe('gallery.no-photo')
344
+ const changed = await doors.updateGalleryPost(
345
+ one.id,
346
+ { caption: '고침', tags: ['봄'] },
347
+ [photo('3.jpg')],
348
+ [photos[0].id],
349
+ as('kim')
350
+ )
351
+ expect(changed).toMatchObject({ caption: '고침', tags: ['봄'] })
352
+ expect((await doors.photos(changed, as('kim'))).map((p: any) => p.name)).toEqual(['2.jpg', '3.jpg'])
353
+
354
+ expect(await doors.deleteGalleryPost(one.id, as('kim'))).toBe(true)
355
+ expect(await listed('kim')).toEqual([])
356
+ expect(await dataSource.getRepository(attachmentBase.Attachment).count()).toBe(0)
357
+ })
358
+ })
359
+
360
+ describe('the menu’s count', () => {
361
+ it('★ counts the posts of others since one last opened the gallery', async () => {
362
+ await post('kim')
363
+ await post('lee')
364
+ expect((await doors.gallerySummary(as('lee'))).fresh).toBe(1)
365
+ await doors.visitGallery(as('lee'))
366
+ expect((await doors.gallerySummary(as('lee'))).fresh).toBe(0)
367
+ /* sqlite keeps a row's creation to the second - the next post comes in a later one. */
368
+ await new Promise(resolve => setTimeout(resolve, 1100))
369
+ await post('kim')
370
+ expect((await doors.gallerySummary(as('lee'))).fresh).toBe(1)
371
+ })
372
+ })