1- import { authUsers , photoAssets , settings , tenantDomains } from '@afilmory/db'
1+ import { authUsers , photoAssets , settings , tenantDomains , tenants } from '@afilmory/db'
22import { DbAccessor } from 'core/database/database.provider'
33import { normalizeDate } from 'core/helpers/normalize.helper'
44import { and , asc , eq , inArray , sql } from 'drizzle-orm'
55import { injectable } from 'tsyringe'
66
7- import { TenantService } from '../tenant/tenant.service'
8-
97@injectable ( )
108export class FeaturedGalleriesService {
11- constructor (
12- private readonly tenantService : TenantService ,
13- private readonly dbAccessor : DbAccessor ,
14- ) { }
9+ constructor ( private readonly dbAccessor : DbAccessor ) { }
1510
1611 async listFeaturedGalleries ( ) {
17- const aggregates = await this . tenantService . listTenants ( )
12+ const db = this . dbAccessor . get ( )
1813
19- // Filter out banned, inactive, and suspended tenants
20- const validTenants = aggregates
21- . filter ( ( aggregate ) => {
22- const { tenant } = aggregate
23- return ! tenant . banned && tenant . status === 'active' && tenant . slug !== 'root' && tenant . slug !== 'placeholder'
24- } )
25- . slice ( 0 , 20 ) // Limit to 20 most recent
14+ // Step 1: Calculate quality scores for all valid tenants with photos
15+ // Quality score formula:
16+ // - Photo count: 1 point per photo
17+ // - Total size: 0.1 points per MB (indicates high quality images)
18+ // - EXIF info: 2 points per photo with EXIF data (indicates professional shooting)
19+ // - Unique tags: 5 points per unique tag (indicates content diversity)
20+ // - GPS info: 1 point per photo with GPS (indicates complete metadata)
21+ const qualityScores = await db . execute < {
22+ tenant_id : string
23+ photo_count : number
24+ total_size_bytes : number
25+ exif_count : number
26+ unique_tag_count : number
27+ gps_count : number
28+ quality_score : number
29+ } > ( sql `
30+ with tenant_quality as (
31+ select
32+ ${ photoAssets . tenantId } as tenant_id,
33+ count(*)::int as photo_count,
34+ coalesce(sum(${ photoAssets . size } ), 0)::bigint as total_size_bytes,
35+ count(case when ${ photoAssets . manifest } ->'data'->'exif'->>'Make' is not null
36+ and ${ photoAssets . manifest } ->'data'->'exif'->>'Make' != '' then 1 end)::int as exif_count,
37+ count(case when ${ photoAssets . manifest } ->'data'->'exif'->'GPSLatitude' is not null
38+ or ${ photoAssets . manifest } ->'data'->'exif'->'GPSLongitude' is not null then 1 end)::int as gps_count
39+ from ${ photoAssets }
40+ where ${ photoAssets . syncStatus } in ('synced', 'conflict')
41+ group by ${ photoAssets . tenantId }
42+ ),
43+ tenant_tags as (
44+ select
45+ ${ photoAssets . tenantId } as tenant_id,
46+ count(distinct tag)::int as unique_tag_count
47+ from ${ photoAssets } ,
48+ lateral jsonb_array_elements_text(${ photoAssets . manifest } ->'data'->'tags') as tag
49+ where ${ photoAssets . syncStatus } in ('synced', 'conflict')
50+ and nullif(trim(tag), '') is not null
51+ group by ${ photoAssets . tenantId }
52+ ),
53+ tenant_scores as (
54+ select
55+ tq.tenant_id,
56+ tq.photo_count,
57+ tq.total_size_bytes,
58+ tq.exif_count,
59+ coalesce(tt.unique_tag_count, 0) as unique_tag_count,
60+ tq.gps_count,
61+ -- Quality score calculation
62+ (tq.photo_count * 1.0 + -- Photo count: 1 point each
63+ (tq.total_size_bytes / 1024.0 / 1024.0) * 0.1 + -- Size: 0.1 points per MB
64+ tq.exif_count * 2.0 + -- EXIF: 2 points each
65+ coalesce(tt.unique_tag_count, 0) * 5.0 + -- Tags: 5 points each
66+ tq.gps_count * 1.0) as quality_score -- GPS: 1 point each
67+ from tenant_quality tq
68+ left join tenant_tags tt on tq.tenant_id = tt.tenant_id
69+ where tq.photo_count > 0
70+ )
71+ select * from tenant_scores
72+ order by quality_score desc
73+ limit 20
74+ ` )
2675
27- const tenantIds = validTenants . map ( ( aggregate ) => aggregate . tenant . id )
28- if ( tenantIds . length === 0 ) {
76+ if ( qualityScores . rows . length === 0 ) {
2977 return { galleries : [ ] }
3078 }
3179
32- const db = this . dbAccessor . get ( )
33-
34- // Fetch site settings for all tenants
35- const siteSettings = await db
80+ const topTenantIds = qualityScores . rows . map ( ( row ) => row . tenant_id )
81+ const scoreMap = new Map (
82+ qualityScores . rows . map ( ( row ) => [
83+ row . tenant_id ,
84+ {
85+ photoCount : row . photo_count ,
86+ totalSizeBytes : row . total_size_bytes ,
87+ exifCount : row . exif_count ,
88+ uniqueTagCount : row . unique_tag_count ,
89+ gpsCount : row . gps_count ,
90+ qualityScore : row . quality_score ,
91+ } ,
92+ ] ) ,
93+ )
94+
95+ // Step 2: Fetch tenant basic info
96+ const tenantRecords = await db
3697 . select ( )
37- . from ( settings )
38- . where ( and ( inArray ( settings . tenantId , tenantIds ) , inArray ( settings . key , [ 'site.name' , 'site.description' ] ) ) )
39-
40- // Fetch primary author (admin) for each tenant
41- const authors = await db
42- . select ( {
43- tenantId : authUsers . tenantId ,
44- name : authUsers . name ,
45- image : authUsers . image ,
46- } )
47- . from ( authUsers )
48- . where ( inArray ( authUsers . tenantId , tenantIds ) )
49- . orderBy (
50- sql `case when ${ authUsers . role } = 'admin' then 0 when ${ authUsers . role } = 'superadmin' then 1 else 2 end` ,
51- asc ( authUsers . createdAt ) ,
52- )
98+ . from ( tenants )
99+ . where ( and ( inArray ( tenants . id , topTenantIds ) , eq ( tenants . banned , false ) , eq ( tenants . status , 'active' ) ) )
53100
54- // Fetch verified domains for all tenants
55- const domains = await db
56- . select ( {
57- tenantId : tenantDomains . tenantId ,
58- domain : tenantDomains . domain ,
59- } )
60- . from ( tenantDomains )
61- . where ( and ( inArray ( tenantDomains . tenantId , tenantIds ) , eq ( tenantDomains . status , 'verified' ) ) )
62-
63- // Fetch photo counts for all tenants (only synced/conflict photos)
64- const photoCounts = await db
65- . select ( {
66- tenantId : photoAssets . tenantId ,
67- count : sql < number > `count(*)::int` ,
68- } )
69- . from ( photoAssets )
70- . where ( and ( inArray ( photoAssets . tenantId , tenantIds ) , inArray ( photoAssets . syncStatus , [ 'synced' , 'conflict' ] ) ) )
71- . groupBy ( photoAssets . tenantId )
101+ // Filter out root and placeholder
102+ const validTenants = tenantRecords . filter ( ( t ) => t . slug !== 'root' && t . slug !== 'placeholder' )
72103
73- // Fetch popular tags for all tenants
74- // This query extracts tags from manifest JSONB and counts them per tenant
75- // Process tags per tenant to ensure proper SQL parameterization
76- const tagMap = new Map < string , string [ ] > ( )
104+ if ( validTenants . length === 0 ) {
105+ return { galleries : [ ] }
106+ }
107+
108+ const finalTenantIds = validTenants . map ( ( t ) => t . id )
109+
110+ // Step 3: Fetch all related data in parallel
111+ const [ siteSettings , authors , domains ] = await Promise . all ( [
112+ // Site settings
113+ db
114+ . select ( )
115+ . from ( settings )
116+ . where (
117+ and ( inArray ( settings . tenantId , finalTenantIds ) , inArray ( settings . key , [ 'site.name' , 'site.description' ] ) ) ,
118+ ) ,
119+ // Primary authors
120+ db
121+ . select ( {
122+ tenantId : authUsers . tenantId ,
123+ name : authUsers . name ,
124+ image : authUsers . image ,
125+ } )
126+ . from ( authUsers )
127+ . where ( inArray ( authUsers . tenantId , finalTenantIds ) )
128+ . orderBy (
129+ sql `case when ${ authUsers . role } = 'admin' then 0 when ${ authUsers . role } = 'superadmin' then 1 else 2 end` ,
130+ asc ( authUsers . createdAt ) ,
131+ ) ,
132+ // Verified domains
133+ db
134+ . select ( {
135+ tenantId : tenantDomains . tenantId ,
136+ domain : tenantDomains . domain ,
137+ } )
138+ . from ( tenantDomains )
139+ . where ( and ( inArray ( tenantDomains . tenantId , finalTenantIds ) , eq ( tenantDomains . status , 'verified' ) ) ) ,
140+ ] )
77141
78- for ( const tenantId of tenantIds ) {
142+ // Step 4: Fetch popular tags for top tenants (batch query)
143+ const tagMap = new Map < string , string [ ] > ( )
144+ for ( const tenantId of finalTenantIds ) {
79145 const tagsResult = await db . execute < { tag : string | null ; count : number | null } > ( sql `
80146 select tag, count(*)::int as count
81147 from (
@@ -102,7 +168,7 @@ export class FeaturedGalleriesService {
102168 }
103169 }
104170
105- // Build maps for quick lookup
171+ // Step 5: Build lookup maps
106172 const settingsMap = new Map < string , Map < string , string | null > > ( )
107173 for ( const setting of siteSettings ) {
108174 if ( ! settingsMap . has ( setting . tenantId ) ) {
@@ -123,43 +189,43 @@ export class FeaturedGalleriesService {
123189
124190 const domainMap = new Map < string , string > ( )
125191 for ( const domain of domains ) {
126- // Use the first verified domain for each tenant
127192 if ( ! domainMap . has ( domain . tenantId ) ) {
128193 domainMap . set ( domain . tenantId , domain . domain )
129194 }
130195 }
131196
132- const photoCountMap = new Map < string , number > ( )
133- for ( const count of photoCounts ) {
134- photoCountMap . set ( count . tenantId , Number ( count . count ?? 0 ) )
135- }
136-
137- // Build response
138- const featuredGalleries = validTenants . map ( ( aggregate ) => {
139- const { tenant } = aggregate
140- const tenantSettings = settingsMap . get ( tenant . id ) ?? new Map ( )
141- const author = authorMap . get ( tenant . id )
142- const domain = domainMap . get ( tenant . id )
143- const photoCount = photoCountMap . get ( tenant . id ) ?? 0
144- const tags = tagMap . get ( tenant . id ) ?? [ ]
145-
146- return {
147- id : tenant . id ,
148- name : tenantSettings . get ( 'site.name' ) ?? tenant . name ,
149- slug : tenant . slug ,
150- domain : domain ?? null ,
151- description : tenantSettings . get ( 'site.description' ) ?? null ,
152- author : author
153- ? {
154- name : author . name ,
155- avatar : author . avatar ,
156- }
157- : null ,
158- photoCount,
159- tags,
160- createdAt : normalizeDate ( tenant . createdAt ) ?? tenant . createdAt ,
161- }
162- } )
197+ // Step 6: Build response sorted by quality score
198+ const featuredGalleries = validTenants
199+ . map ( ( tenant ) => {
200+ const tenantSettings = settingsMap . get ( tenant . id ) ?? new Map ( )
201+ const author = authorMap . get ( tenant . id )
202+ const domain = domainMap . get ( tenant . id )
203+ const tags = tagMap . get ( tenant . id ) ?? [ ]
204+ const score = scoreMap . get ( tenant . id )
205+
206+ return {
207+ id : tenant . id ,
208+ name : tenantSettings . get ( 'site.name' ) ?? tenant . name ,
209+ slug : tenant . slug ,
210+ domain : domain ?? null ,
211+ description : tenantSettings . get ( 'site.description' ) ?? null ,
212+ author : author
213+ ? {
214+ name : author . name ,
215+ avatar : author . avatar ,
216+ }
217+ : null ,
218+ photoCount : score ?. photoCount ?? 0 ,
219+ tags,
220+ createdAt : normalizeDate ( tenant . createdAt ) ?? tenant . createdAt ,
221+ }
222+ } )
223+ . filter ( ( gallery ) => gallery . photoCount > 0 )
224+ . sort ( ( a , b ) => {
225+ const scoreA = scoreMap . get ( a . id ) ?. qualityScore ?? 0
226+ const scoreB = scoreMap . get ( b . id ) ?. qualityScore ?? 0
227+ return scoreB - scoreA
228+ } )
163229
164230 return {
165231 galleries : featuredGalleries ,
0 commit comments