Skip to content

Commit

Permalink
feat: implement dynamic generation of business visa
Browse files Browse the repository at this point in the history
  • Loading branch information
beeman committed Apr 7, 2024
1 parent beda1a7 commit f3669c4
Show file tree
Hide file tree
Showing 34 changed files with 1,004 additions and 76 deletions.
1 change: 1 addition & 0 deletions api-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ type AppConfig {

type Asset {
account: String!
attributes: [[String!]!]!
description: String!
image: String!
lists: [AssetActivityType!]!
Expand Down
Binary file added apps/api/src/assets/images/background.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/api/src/assets/images/brand/default-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/api/src/assets/images/brand/default-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/api/src/assets/images/brand/pubkey-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/api/src/assets/images/not-found.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/api/src/assets/images/powered-by-md.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/api/src/assets/images/powered-by-sm.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/api/src/assets/images/templates/business-visa.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/api/src/assets/images/templates/citizenship.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/api/src/assets/images/templates/residence-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/api/src/assets/images/templates/visitor-pass.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/api/tsconfig.app.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"],
Expand Down
6 changes: 5 additions & 1 deletion apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
}
],
"compilerOptions": {
"esModuleInterop": true
"esModuleInterop": true,
"jsx": "preserve",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext",
"module": "ESNext"
}
}
1 change: 1 addition & 0 deletions libs/api/asset/data-access/src/lib/api-asset.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class ApiAssetService {
description: json.description,
image: json.image,
lists: this.getLists('business-visa'),
attributes: json.attributes.map((attr) => [attr.trait_type, attr.value]),
}
}

Expand Down
2 changes: 2 additions & 0 deletions libs/api/asset/data-access/src/lib/entity/asset.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ export class Asset {
image!: string
@Field(() => [AssetActivityType])
lists!: AssetActivityType[]
@Field(() => [[String]])
attributes!: [string, string][]
}
1 change: 1 addition & 0 deletions libs/api/core/data-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './lib/entity/currency.entity'
export * from './lib/entity/paging-meta.entity'
export * from './lib/entity/paging-response.entity'
export * from './lib/helpers/ellipsify'
export * from './lib/helpers/minutes'
export * from './lib/helpers/hash-validate-password'
export * from './lib/helpers/serve-static-factory'
export * from './lib/helpers/slugify-id'
3 changes: 3 additions & 0 deletions libs/api/core/data-access/src/lib/helpers/minutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const ONE_MINUTE = 1000 * 60
export const TEN_MINUTES = ONE_MINUTE * 10
export const ONE_HOUR = TEN_MINUTES * 6
222 changes: 212 additions & 10 deletions libs/api/metadata/data-access/src/lib/api-metadata-image.service.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,94 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'
import { ApiCoreService } from '@tokengator/api-core-data-access'
import { Community, Identity, Preset, User } from '@prisma/client'
import { ApiCoreService, ONE_MINUTE } from '@tokengator/api-core-data-access'
import { IdentityProvider } from '@tokengator/api-identity-data-access'
import { Buffer } from 'buffer'
import { Font } from 'canvacord'
import { LRUCache } from 'lru-cache'
import * as fs from 'node:fs/promises'
import { join } from 'node:path'
import { CantFindTheRightTypeScrewItHackathonMode } from './api-metadata.service'
import { ExtensionTokenMetadata } from './api-metadata.service'
import { GenerateBusinessVisaImage } from './generators/generate-business-visa-image'

type IdentityWithProfile = Omit<Identity, 'profile'> & { profile: { username: string } }
type UserWithIdentities = User & { identities: IdentityWithProfile[] }

@Injectable()
export class ApiMetadataImageService implements OnModuleInit {
private readonly logger = new Logger(ApiMetadataImageService.name)
private readonly assetPath = join(__dirname, 'assets')
private readonly imagePath = join(this.assetPath, 'images')
private readonly fontPath = join(this.assetPath, 'fonts')
private readonly brandPath = join(this.imagePath, 'brand')
private readonly brandMap = new Map<string, Buffer>()
private readonly templatePath = join(this.imagePath, 'templates')

private readonly templateMap = new Map<string, Buffer>()

private readonly communityCache = new LRUCache<string, Community>({
max: 1000,
ttl: ONE_MINUTE,
fetchMethod: async (slug: string) => {
const found = await this.core.data.community.findUnique({ where: { slug } })
if (found) {
this.logger.verbose(`Caching community info for ${slug}`)
return found
}
throw new Error(`Failed to fetch community info for ${slug}`)
},
})

private readonly presetCache = new LRUCache<string, Preset>({
max: 1000,
ttl: ONE_MINUTE,
fetchMethod: async (id: string) => {
const found = await this.core.data.preset.findUnique({ where: { id } })
if (found) {
this.logger.verbose(`Caching preset info for ${id}`)
return found
}
throw new Error(`Failed to fetch preset info for ${id}`)
},
})

private readonly userCache = new LRUCache<string, UserWithIdentities>({
max: 1000,
ttl: ONE_MINUTE,
fetchMethod: async (username: string) => {
const found = (await this.core.data.user.findUnique({
where: { username },
include: { identities: true },
})) as UserWithIdentities
if (found) {
this.logger.verbose(`Caching user info for ${username}`)
return found
}
throw new Error(`Failed to fetch user info for ${username}`)
},
})

constructor(private readonly core: ApiCoreService) {}

async onModuleInit(): Promise<void> {
await this.listTemplates()
await this.loadFonts()
await this.loadTemplates()
await this.loadBrands()
}

async listTemplates() {
async loadFonts() {
Font.loadDefault()
const families = await fs.readdir(this.fontPath)
this.logger.verbose(`Found ${families.length} fonts in ${this.fontPath}`)

for (const family of families) {
const fonts = await fs.readdir(join(this.fontPath, family))
this.logger.verbose(`Found ${fonts.length} fonts in ${family}`)
for (const font of fonts) {
Font.fromFileSync(join(this.fontPath, family, font), font.replace('.ttf', ''))
this.logger.verbose(` -> Loaded font: ${family}/${font} -> ${font.replace('.ttf', '')}`)
}
}
}
async loadTemplates() {
const files = await fs.readdir(this.templatePath)
this.logger.verbose(`Found ${files.length} templates in ${this.templatePath}`)

Expand All @@ -31,11 +99,145 @@ export class ApiMetadataImageService implements OnModuleInit {
}
}

generate(accountMetadata: CantFindTheRightTypeScrewItHackathonMode) {
const rand = Math.floor(Math.random() * this.templateMap.size)
const template = Array.from(this.templateMap.values())[rand]
// Do something with the template
async loadBrands() {
const files = await fs.readdir(this.brandPath)
this.logger.verbose(`Found ${files.length} brands in ${this.brandPath}`)

for (const file of files) {
const buffer = await fs.readFile(join(this.brandPath, file))
this.brandMap.set(file, buffer)
this.logger.verbose(` -> Loaded brand: ${file}`)
}
// Make sure we have a default icon and logo
if (!this.brandMap.has('default-icon.png')) {
throw new Error(`Missing default-icon.png. Expected in brand folder: ${this.brandPath}`)
}
if (!this.brandMap.has('default-logo.png')) {
throw new Error(`Missing default-logo.png. Expected in brand folder: ${this.brandPath}`)
}
}

async generate(account: string, accountMetadata: ExtensionTokenMetadata): Promise<Buffer> {
const community = accountMetadata.state.additionalMetadata.find(([key]) => key === 'community')?.[1]
const username = accountMetadata.state.additionalMetadata.find(([key]) => key === 'username')?.[1]
const preset = accountMetadata.state.additionalMetadata.find(([key]) => key === 'preset')?.[1]

if (!community || !username || !preset) {
throw new Error('Missing metadata. Required: community, username, preset')
}

const [foundCommunity, foundUser, foundPreset] = await Promise.all([
this.communityCache.fetch(community),
this.userCache.fetch(username),
this.presetCache.fetch(preset),
])

if (!foundCommunity) {
throw new Error(`Community not found: ${community}`)
}
if (!foundUser) {
throw new Error(`User not found: ${username}`)
}
if (!foundPreset) {
throw new Error(`Preset not found: ${preset}`)
}

switch (foundPreset.id) {
case 'business-visa':
return this.renderBusinessVisa({
user: foundUser,
community: foundCommunity,
preset: foundPreset,
account,
metadata: accountMetadata,
})
default:
return this.renderNotFound()
}
}

renderNotFound() {
return fs.readFile(join(this.imagePath, 'not-found.png'))
}

renderPoweredBy() {
return fs.readFile(join(this.imagePath, 'powered-by-md.png'))
}

private async renderIcon(community: Community) {
const icon = this.brandMap.get(`${community.slug}-icon.png`)

return icon ? icon : this.brandMap.get('default-icon.png')
}

private async renderLogo(community: Community) {
const logo = this.brandMap.get(`${community.slug}-logo.png`)

return logo ? logo : this.brandMap.get('default-logo.png')
}

async renderBusinessVisa({
user,
community,
preset,
account,
metadata,
}: {
user: User & { identities: IdentityWithProfile[] }
community: Community
preset: Preset
account: string
metadata: ExtensionTokenMetadata
}): Promise<Buffer> {
const templateName = `${preset.id}.png`
const template = this.templateMap.get(templateName)

if (!template) {
throw new Error(`Template not found: ${templateName}`)
}

const poweredBy = await this.renderPoweredBy()
const icon = await this.renderIcon(community)
const logo = await this.renderLogo(community)

if (!icon || !logo) {
throw new Error('Missing icon or logo. Expected in brand folder')
}

const githubIdentity = user.identities.find((i) => i.provider === IdentityProvider.GitHub)
const twitterIdentity = user.identities.find((i) => i.provider === IdentityProvider.Twitter)

const templateData = {
left: {
username: user.username,
community: community.name,
twitter: twitterIdentity?.profile?.username ? `x.com/${twitterIdentity.profile?.username}` : '',
github: githubIdentity?.profile?.username ? `x.com/${githubIdentity.profile?.username}` : '',
},
right: {
status: 'Active',
points: '1000',
issuedAt: '2024-01-01',
expiresAt: '2025-01-01',
},
}

const imageBuilder = new GenerateBusinessVisaImage({
width: 1024,
height: 1024,
earnings: '1000',
status: 'Active',
community: community.name,
name: user.username,
avatar: user.avatarUrl ?? '',
background: template,
poweredBy,
icon,
logo,
})

const image: Buffer = (await imageBuilder.build({ format: 'png' })) as Buffer

return template
return image
}
}
Loading

0 comments on commit f3669c4

Please sign in to comment.