diff --git a/app/Controllers/Http/ParticipationImagesController.ts b/app/Controllers/Http/ParticipationImagesController.ts index b65b2c8..fbfc740 100644 --- a/app/Controllers/Http/ParticipationImagesController.ts +++ b/app/Controllers/Http/ParticipationImagesController.ts @@ -14,6 +14,47 @@ import InvalidInput from 'App/Exceptions/InvalidInput' import { selectedImageIds } from 'App/Helpers/coCreatorAttribution' export default class ParticipationImagesController extends BaseController { + /** + * Contributions for a set, paginated. + * + * Previously these were embedded in the set submission response in full, + * which meant every visitor downloaded every contribution before the page + * could paint. Filter by `filter[creatorAddress]` to page through a single + * artist's work. + */ + public async list({ params, request }: HttpContextContract) { + const { page = 1, limit = 24, filter = {}, sort = '-createdAt' } = request.qs() + + const submission = await SetSubmission.query().where('uuid', params.id).firstOrFail() + + const query = ParticipationImage.query() + .where('setSubmissionId', submission.id) + .whereNull('deletedAt') + .preload('image') + .preload('creator', (creatorQuery) => creatorQuery.preload('pfp')) + + await this.applyFilters(query, filter) + await this.applySorts(query, sort) + + return query.paginate(page, limit) + } + + /** Contribution and contributor counts, so a client can size a list without fetching it. */ + public async stats({ params }: HttpContextContract) { + const submission = await SetSubmission.query().where('uuid', params.id).firstOrFail() + + const [contributions] = await Database.from('participation_images') + .where('set_submission_id', submission.id) + .whereNull('deleted_at') + .count('* as total') + .countDistinct('creator_address as contributors') + + return { + contributions: Number(contributions.total), + contributors: Number(contributions.contributors), + } + } + public async store({ request, session }: HttpContextContract) { const address = session.get('siwe')?.address?.toLowerCase() if (!address) throw new NotAuthenticated() diff --git a/app/Controllers/Http/SetSubmissionsController.ts b/app/Controllers/Http/SetSubmissionsController.ts index 87db846..2846fbf 100644 --- a/app/Controllers/Http/SetSubmissionsController.ts +++ b/app/Controllers/Http/SetSubmissionsController.ts @@ -155,7 +155,10 @@ export default class SetSubmissionsController extends BaseController { return submission } - public async show({ params }: HttpContextContract) { + public async show({ params, request }: HttpContextContract) { + const { includes = [] } = request.qs() + const requestedIncludes = Array.isArray(includes) ? includes : [includes] + const submission = await SetSubmission.query() .where('uuid', params.id) .preload('set') @@ -177,12 +180,20 @@ export default class SetSubmissionsController extends BaseController { query.preload('cover') query.orderBy('sortIndex') }) - .preload('participationImages', (query) => { - query.whereNull('deletedAt') - query.preload('image') - query.preload('creator', (creatorQuery) => creatorQuery.preload('pfp')) - query.orderBy('createdAt', 'desc') - }) + /* + * Only embedded when asked for. A busy set carries thousands of + * contributions, each with its image and creator, which made this + * response several megabytes and dwarfed everything else on it. + * Use the paginated `/:id/participation` route instead. + */ + .if(requestedIncludes.includes('participationImages'), (query) => + query.preload('participationImages', (participationQuery) => { + participationQuery.whereNull('deletedAt') + participationQuery.preload('image') + participationQuery.preload('creator', (creatorQuery) => creatorQuery.preload('pfp')) + participationQuery.orderBy('createdAt', 'desc') + }), + ) .firstOrFail() // TODO: Implement rich content links // .preload('richContentLinks', query => { diff --git a/start/routes.ts b/start/routes.ts index cd0248f..38cab95 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -117,6 +117,9 @@ Route.group(() => { Route.post('/:id/subscribe', 'SetSubscriptionsController.subscribe') }).middleware(['auth']) + Route.get('/:id/participation', 'ParticipationImagesController.list') + Route.get('/:id/participation-stats', 'ParticipationImagesController.stats') + Route.get('/:id/subscribers', 'SetSubscriptionsController.listSubscribers') Route.get('/:id/history', 'SetSubscriptionsController.history') Route.get('/:id/nodes-stats', 'SetSubscriptionsController.nodesStats')