diff --git a/Agents.md b/Agents.md index 41e66d2..90cde54 100644 --- a/Agents.md +++ b/Agents.md @@ -102,3 +102,30 @@ Operational guide for AI/coding agents working in `consumer_api`. - Duplicate FK error seen in this repo: `sales_invoices_ref_id_fkey` (MySQL 1826). Recheck migration SQL for repeated `ADD CONSTRAINT` statements before rerun. - Drift that repeatedly showed in this project: missing FK/unique on `sale_invoice_tsp_attempts.invoice_id`. Validate both schema and migration SQL produce the same final state. - `prisma migrate status` may show up-to-date while `migrate dev` still detects drift (shadow DB/application history issue); treat `migrate dev` output as source of truth for fixing local history. + +## Redis Cache Conventions (May 2026) +- Use `RedisKeyMaker` in `src/common/utils/redis-key-maker.util.ts` for all cache keys and wildcard patterns; do not inline key strings in services. +- Keep invalidation domain-based (for example `src/modules/admin/guilds/cache/*`, `src/modules/admin/partners/cache/*`, `src/modules/pos/cache/*`) and avoid duplicating delete logic across modules. +- For list APIs that are expensive or frequently read, prefer read-through cache with TTL and invalidation on every related write path. +- For entity endpoints, use list + detail split: + - list key(s): invalidated broadly on writes, + - detail key(s): invalidated per entity id. +- Partner domain rules: + - Shared partner cache namespace is `partners:*` (not admin-prefixed) because writes occur in both `admin/partners` and `partners` modules. + - Use shared invalidation service `src/modules/partners/cache/partners-cache-invalidation.service.ts` from both admin and partner write paths. + - Invalidate `partners:list` and `partners:{id}:detail` on partner create/update/delete and license-affecting writes. + - Invalidate partner license caches (activated-licenses list, charge-transactions list/detail) via the shared invalidation service. +- POS goods rules: + - Cache key shape is BA + guild scoped (`pos:ba:{businessActivityId}:guild:{guildId}:goods:list`) because results combine guild-default and BA-owned goods. + - Invalidate POS goods cache by guild when admin guild goods/category/sku changes. + - Invalidate POS goods cache by business activity when consumer BA goods create/update/delete. +- Consumer/Partner hierarchy rules: + - Consumer profile cache key: `consumers:{consumerId}:info`. + - Partner-consumer profile cache key: `partners:{partnerId}:consumers:{consumerId}:info`. + - On partner-consumer single read, write both keys when practical to share warm cache across modules. + - On consumer info update or partner-consumer update, invalidate both consumer and partner-consumer profile keys. + - For business activities, invalidate both list and detail layers; child updates may invalidate parent single cache when parent aggregates depend on child state. +- Wildcard invalidation implementation: + - Use `RedisService.deleteByPattern` / `RedisService.deleteByPatterns` for all pattern deletes. + - Do not implement ad-hoc scan/delete loops inside domain services. + - Pattern deletion uses `SCAN` + pipelined `DEL`; multi-pattern deletes run in parallel. diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index c09280f..6cc9ba7 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1,6 +1,7 @@ -export * from './jwt-user.util' export * from './enum-translator.util' +export * from './http-client.util' +export * from './jwt-user.util' export * from './mappers/consumer_mappers.util' export * from './password.util' +export * from './redisKeyMaker' export * from './tracking-code-generator.util' -export * from './http-client.util' diff --git a/src/common/utils/redisKeyMaker/consumer.ts b/src/common/utils/redisKeyMaker/consumer.ts new file mode 100644 index 0000000..dd48f54 --- /dev/null +++ b/src/common/utils/redisKeyMaker/consumer.ts @@ -0,0 +1,16 @@ +export class ConsumerKeyMaker { + static consumerInfo(consumerId: string): string { + return `consumers:${consumerId}:info` + } + + static consumerBusinessActivitiesList(consumerId: string): string { + return `consumers:${consumerId}:business-activities:list` + } + + static consumerBusinessActivityInfo( + consumerId: string, + businessActivityId: string, + ): string { + return `consumers:${consumerId}:business-activities:${businessActivityId}:info` + } +} diff --git a/src/common/utils/redisKeyMaker/enum.ts b/src/common/utils/redisKeyMaker/enum.ts new file mode 100644 index 0000000..da5b7a2 --- /dev/null +++ b/src/common/utils/redisKeyMaker/enum.ts @@ -0,0 +1,9 @@ +export class EnumKeyMaker { + static enumsAll(buildVersion: string): string { + return `enums:${buildVersion}:all` + } + + static enumsValues(buildVersion: string, enumName: string): string { + return `enums:${buildVersion}:values:${enumName}` + } +} diff --git a/src/common/utils/redisKeyMaker/guild.ts b/src/common/utils/redisKeyMaker/guild.ts new file mode 100644 index 0000000..4550515 --- /dev/null +++ b/src/common/utils/redisKeyMaker/guild.ts @@ -0,0 +1,9 @@ +export class GuildKeyMaker { + static guildStockKeepingUnitsList(guildId: string): string { + return `guilds:${guildId}:stock-keeping-units:list` + } + + static guildGoodsList(guildId: string): string { + return `guilds:${guildId}:goods:list` + } +} diff --git a/src/common/utils/redisKeyMaker/index.ts b/src/common/utils/redisKeyMaker/index.ts new file mode 100644 index 0000000..cf82026 --- /dev/null +++ b/src/common/utils/redisKeyMaker/index.ts @@ -0,0 +1,30 @@ +import { ConsumerKeyMaker } from './consumer' +import { EnumKeyMaker } from './enum' +import { GuildKeyMaker } from './guild' +import { PartnerKeyMaker } from './partner' +import { PosKeyMaker } from './pos' + +// Keep backward-compatible static methods while separating key builders by domain. +export class RedisKeyMaker extends PartnerKeyMaker { + static consumer = ConsumerKeyMaker + static enums = EnumKeyMaker + static guild = GuildKeyMaker + static partner = PartnerKeyMaker + static pos = PosKeyMaker + + static consumerInfo = ConsumerKeyMaker.consumerInfo + static consumerBusinessActivitiesList = ConsumerKeyMaker.consumerBusinessActivitiesList + static consumerBusinessActivityInfo = ConsumerKeyMaker.consumerBusinessActivityInfo + + static guildStockKeepingUnitsList = GuildKeyMaker.guildStockKeepingUnitsList + static guildGoodsList = GuildKeyMaker.guildGoodsList + + static posInfo = PosKeyMaker.posInfo + static posMe = PosKeyMaker.posMe + static posGoodsList = PosKeyMaker.posGoodsList + + static enumsAll = EnumKeyMaker.enumsAll + static enumsValues = EnumKeyMaker.enumsValues +} + +export { ConsumerKeyMaker, EnumKeyMaker, GuildKeyMaker, PartnerKeyMaker, PosKeyMaker } diff --git a/src/common/utils/redisKeyMaker/partner.ts b/src/common/utils/redisKeyMaker/partner.ts new file mode 100644 index 0000000..8539598 --- /dev/null +++ b/src/common/utils/redisKeyMaker/partner.ts @@ -0,0 +1,135 @@ +export class PartnerKeyMaker { + static partnersList(): string { + return 'partners:list' + } + + static partnerDetail(partnerId: string): string { + return `partners:${partnerId}:detail` + } + + static partnerActivatedLicensesList( + partnerId: string, + page: number, + perPage: number, + ): string { + return `partners:${partnerId}:activated-licenses:list:${page}:${perPage}` + } + + static partnerActivatedLicensesListPattern(partnerId: string): string { + return `partners:${partnerId}:activated-licenses:list:*` + } + + static partnerLicenseChargeTransactionsList( + partnerId: string, + page: number, + perPage: number, + ): string { + return `partners:${partnerId}:license-charge-transactions:list:${page}:${perPage}` + } + + static partnerLicenseChargeTransactionsListPattern(partnerId: string): string { + return `partners:${partnerId}:license-charge-transactions:list:*` + } + + static partnerLicenseChargeTransactionDetail( + partnerId: string, + transactionId: string, + ): string { + return `partners:${partnerId}:license-charge-transactions:${transactionId}:detail` + } + + static partnerLicenseChargeTransactionDetailPattern(partnerId: string): string { + return `partners:${partnerId}:license-charge-transactions:*:detail` + } + + //////////////////// consumers //////////////////// + + static partnerConsumersPattern(partnerId: string): string { + return `partners:${partnerId}:consumers:*` + } + + static partnerConsumerPattern(partnerId: string): string { + return this.partnerConsumersPattern(partnerId) + } + static partnerConsumersListPattern(partnerId: string): string { + return `partners:${partnerId}:consumers:list:*` + } + + static partnerConsumersList(partnerId: string, page: number, perPage: number): string { + return `partners:${partnerId}:consumers:list:${page}:${perPage}` + } + + static partnerConsumerInfo(partnerId: string, consumerId: string): string { + return `partners:${partnerId}:consumers:${consumerId}:info` + } + + static partnerConsumerBusinessActivitiesList( + partnerId: string, + consumerId: string, + page: number, + perPage: number, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:list:${page}:${perPage}` + } + + static partnerConsumerBusinessActivitiesPattern( + partnerId: string, + consumerId: string, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:*` + } + + static partnerConsumerBusinessActivityInfo( + partnerId: string, + consumerId: string, + businessActivityId: string, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:${businessActivityId}:info` + } + + static partnerConsumerBusinessActivityComplexesList( + partnerId: string, + consumerId: string, + businessActivityId: string, + page: number, + perPage: number, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:${businessActivityId}:complexes:list:${page}:${perPage}` + } + + static partnerConsumerBusinessActivityComplexesPattern( + partnerId: string, + consumerId: string, + businessActivityId: string, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:${businessActivityId}:complexes:*` + } + + static partnerConsumerBusinessActivityComplexInfo( + partnerId: string, + consumerId: string, + businessActivityId: string, + complexId: string, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:${businessActivityId}:complexes:${complexId}` + } + + static partnerConsumerBusinessActivityComplexPosesPattern( + partnerId: string, + consumerId: string, + businessActivityId: string, + complexId: string, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:${businessActivityId}:complexes:${complexId}:poses:*` + } + + static partnerConsumerBusinessActivityComplexPosInfo( + partnerId: string, + consumerId: string, + businessActivityId: string, + complexId: string, + posId: string, + ): string { + return `partners:${partnerId}:consumers:${consumerId}:business-activities:${businessActivityId}:complexes:${complexId}:poses:${posId}` + } +} diff --git a/src/common/utils/redisKeyMaker/pos.ts b/src/common/utils/redisKeyMaker/pos.ts new file mode 100644 index 0000000..7e2b1c3 --- /dev/null +++ b/src/common/utils/redisKeyMaker/pos.ts @@ -0,0 +1,13 @@ +export class PosKeyMaker { + static posInfo(posId: string): string { + return `pos:${posId}:info` + } + + static posMe(accountId: string, posId: string): string { + return `pos:${posId}:me:${accountId}` + } + + static posGoodsList(businessActivityId: string, guildId: string): string { + return `pos:ba:${businessActivityId}:guild:${guildId}:goods:list` + } +} diff --git a/src/modules/admin/guilds/cache/admin-guild-cache-invalidation.service.ts b/src/modules/admin/guilds/cache/admin-guild-cache-invalidation.service.ts new file mode 100644 index 0000000..31e9b16 --- /dev/null +++ b/src/modules/admin/guilds/cache/admin-guild-cache-invalidation.service.ts @@ -0,0 +1,17 @@ +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' +import { PosCacheInvalidationService } from '@/modules/pos/cache/pos-cache-invalidation.service' +import { RedisService } from '@/redis/redis.service' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class AdminGuildCacheInvalidationService { + constructor( + private readonly redisService: RedisService, + private readonly posCacheInvalidationService: PosCacheInvalidationService, + ) {} + + async invalidateGoodsList(guildId: string): Promise { + await this.redisService.delete(RedisKeyMaker.guildGoodsList(guildId)) + await this.posCacheInvalidationService.invalidateGoodsListByGuild(guildId) + } +} diff --git a/src/modules/admin/guilds/good-categories/good-categories.service.ts b/src/modules/admin/guilds/good-categories/good-categories.service.ts index a9583e6..273d86b 100644 --- a/src/modules/admin/guilds/good-categories/good-categories.service.ts +++ b/src/modules/admin/guilds/good-categories/good-categories.service.ts @@ -1,5 +1,6 @@ import { GoodCategoryOmit } from '@/generated/prisma/models' import { PrismaService } from '@/prisma/prisma.service' +import { AdminGuildCacheInvalidationService } from '@/modules/admin/guilds/cache/admin-guild-cache-invalidation.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' import { @@ -9,7 +10,10 @@ import { @Injectable() export class GoodCategoriesService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private readonly cacheInvalidationService: AdminGuildCacheInvalidationService, + ) {} private readonly goodCategoriesOmit = { complex_id: true, @@ -67,8 +71,8 @@ export class GoodCategoriesService { }) } - create(guild_id: string, data: CreateGoodCategoryDto) { - const category = this.prisma.goodCategory.create({ + async create(guild_id: string, data: CreateGoodCategoryDto) { + const category = await this.prisma.goodCategory.create({ data: { guild: { connect: { @@ -83,6 +87,8 @@ export class GoodCategoriesService { omit: this.goodCategoriesOmit, }) + await this.cacheInvalidationService.invalidateGoodsList(guild_id) + return ResponseMapper.create({ ...category, goods_count: 0 }) } @@ -113,6 +119,8 @@ export class GoodCategoriesService { omit: this.goodCategoriesOmit, }) + await this.cacheInvalidationService.invalidateGoodsList(guild_id) + return ResponseMapper.update({ ...category, goods_count: Number(category?._count.goods), diff --git a/src/modules/admin/guilds/goods/goods.service.ts b/src/modules/admin/guilds/goods/goods.service.ts index 52374e1..fb7c613 100644 --- a/src/modules/admin/guilds/goods/goods.service.ts +++ b/src/modules/admin/guilds/goods/goods.service.ts @@ -1,7 +1,10 @@ import { UploadedFileTypes } from '@/common/enums/enums' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { GoodSelect, GoodWhereInput } from '@/generated/prisma/models' +import { AdminGuildCacheInvalidationService } from '@/modules/admin/guilds/cache/admin-guild-cache-invalidation.service' import { UploaderService } from '@/modules/uploader/uploader.service' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' import { CreateGuildGoodDto, UpdateGuildGoodDto } from './dto/create-good.dto' @@ -11,8 +14,12 @@ export class GoodsService { constructor( private prisma: PrismaService, private readonly uploaderService: UploaderService, + private readonly redisService: RedisService, + private readonly cacheInvalidationService: AdminGuildCacheInvalidationService, ) {} + private readonly listCacheTtlSeconds = 300 + private readonly defaultSelect: GoodSelect = { id: true, name: true, @@ -49,6 +56,14 @@ export class GoodsService { }) async findAll(guildId: string) { + const cacheKey = RedisKeyMaker.guildGoodsList(guildId) + const cached = await this.redisService.getJson<{ goods: unknown[]; total: number }>( + cacheKey, + ) + if (cached) { + return ResponseMapper.paginate(cached.goods, { total: cached.total }) + } + const [goods, total] = await this.prisma.$transaction([ this.prisma.good.findMany({ where: this.defaultWhere(guildId), @@ -56,6 +71,9 @@ export class GoodsService { }), this.prisma.good.count(), ]) + + await this.redisService.setJson(cacheKey, { goods, total }, this.listCacheTtlSeconds) + return ResponseMapper.paginate(goods, { total, }) @@ -75,6 +93,10 @@ export class GoodsService { async create(data: CreateGuildGoodDto, file: Express.Multer.File) { const { category_id, measure_unit_id, sku_id, ...rest } = data + const category = await this.prisma.goodCategory.findUnique({ + where: { id: category_id }, + select: { guild_id: true }, + }) const good = await this.prisma.$transaction(async tx => { let image_url = '' @@ -111,11 +133,25 @@ export class GoodsService { }) }) + if (category?.guild_id) { + await this.cacheInvalidationService.invalidateGoodsList(category.guild_id) + } + return ResponseMapper.create(good) } async update(id: string, data: UpdateGuildGoodDto, file: Express.Multer.File) { const { category_id, measure_unit_id, sku_id, ...rest } = data + const prevGood = await this.prisma.good.findUnique({ + where: { id }, + select: { category: { select: { guild_id: true } } }, + }) + const nextCategory = category_id + ? await this.prisma.goodCategory.findUnique({ + where: { id: category_id }, + select: { guild_id: true }, + }) + : null const good = await this.prisma.$transaction(async tx => { let image_url = '' @@ -162,6 +198,18 @@ export class GoodsService { }) }) + const cacheGuildIds = new Set() + if (prevGood?.category?.guild_id) { + cacheGuildIds.add(prevGood.category.guild_id) + } + if (nextCategory?.guild_id) { + cacheGuildIds.add(nextCategory.guild_id) + } + + for (const guildId of cacheGuildIds) { + await this.cacheInvalidationService.invalidateGoodsList(guildId) + } + return ResponseMapper.create(good) } } diff --git a/src/modules/admin/guilds/stock-keeping-units/stock-keeping-units.service.ts b/src/modules/admin/guilds/stock-keeping-units/stock-keeping-units.service.ts index 4b6d711..6b42198 100644 --- a/src/modules/admin/guilds/stock-keeping-units/stock-keeping-units.service.ts +++ b/src/modules/admin/guilds/stock-keeping-units/stock-keeping-units.service.ts @@ -1,4 +1,7 @@ +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' +import { AdminGuildCacheInvalidationService } from '@/modules/admin/guilds/cache/admin-guild-cache-invalidation.service' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' import { CreateStockKeepingUnitDto } from './dto/create-stock-keeping-unit.dto' @@ -6,7 +9,17 @@ import { UpdateStockKeepingUnitDto } from './dto/update-stock-keeping-unit.dto' @Injectable() export class StockKeepingUnitsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly cacheInvalidationService: AdminGuildCacheInvalidationService, + ) {} + + private readonly listCacheTtlSeconds = 300 + + private getListCacheKey(guild_id: string): string { + return RedisKeyMaker.guildStockKeepingUnitsList(guild_id) + } private mapData(data: any) { const { name, is_foreign, is_domestic, ...rest } = data @@ -17,12 +30,19 @@ export class StockKeepingUnitsService { } async findAll(guild_id: string) { + const cacheKey = this.getListCacheKey(guild_id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.list(cached) + } + const items = await this.prisma.stockKeepingUnits.findMany({ where: { guild_id }, orderBy: { created_at: 'desc' }, }) const mappedData = items.map(this.mapData) + await this.redisService.setJson(cacheKey, mappedData, this.listCacheTtlSeconds) return ResponseMapper.list(mappedData) } @@ -39,6 +59,9 @@ export class StockKeepingUnitsService { }, }) + await this.redisService.delete(this.getListCacheKey(guild_id)) + await this.cacheInvalidationService.invalidateGoodsList(guild_id) + const mappedData = this.mapData(item) return ResponseMapper.create(mappedData) } @@ -48,6 +71,10 @@ export class StockKeepingUnitsService { where: { guild_id, id }, data, }) + + await this.redisService.delete(this.getListCacheKey(guild_id)) + await this.cacheInvalidationService.invalidateGoodsList(guild_id) + return ResponseMapper.update(item) } } diff --git a/src/modules/admin/partners/activatedLicenses/activatedLicenses.service.ts b/src/modules/admin/partners/activatedLicenses/activatedLicenses.service.ts index 218803b..e7a1290 100644 --- a/src/modules/admin/partners/activatedLicenses/activatedLicenses.service.ts +++ b/src/modules/admin/partners/activatedLicenses/activatedLicenses.service.ts @@ -1,15 +1,30 @@ import { QUERY_CONSTANTS } from '@/common/queryConstants' import consumer_mappersUtil from '@/common/utils/mappers/consumer_mappers.util' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { LicenseActivationWhereInput } from '@/generated/prisma/models' +import { PartnersCacheInvalidationService } from '@/modules/partners/cache/partners-cache-invalidation.service' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' @Injectable() export class PartnerActivatedLicensesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly cacheInvalidationService: PartnersCacheInvalidationService, + ) {} async findAll(partner_id: string, page = 1, perPage = 10) { + const cacheKey = RedisKeyMaker.partnerActivatedLicensesList(partner_id, page, perPage) + const cached = await this.redisService.getJson<{ data: unknown[]; total: number }>( + cacheKey, + ) + if (cached) { + return ResponseMapper.paginate(cached.data, { page, perPage, total: cached.total }) + } + const defaultWhere: LicenseActivationWhereInput = { license: { charge_transaction: { @@ -73,6 +88,8 @@ export class PartnerActivatedLicensesService { } }) + await this.redisService.setJson(cacheKey, { data: mappedLicenses, total }, 300) + return ResponseMapper.paginate(mappedLicenses, { page, perPage, @@ -90,6 +107,7 @@ export class PartnerActivatedLicensesService { async delete(partnerId: string, id: string) { await this.prisma.license.delete({ where: { id } }) + await this.cacheInvalidationService.invalidatePartnerLicenses(partnerId) return ResponseMapper.delete() } } diff --git a/src/modules/admin/partners/chargedLicenseTransactions/chargedLicenseTransactions.service.ts b/src/modules/admin/partners/chargedLicenseTransactions/chargedLicenseTransactions.service.ts index 2cd3d4b..8547690 100644 --- a/src/modules/admin/partners/chargedLicenseTransactions/chargedLicenseTransactions.service.ts +++ b/src/modules/admin/partners/chargedLicenseTransactions/chargedLicenseTransactions.service.ts @@ -1,3 +1,4 @@ +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { generateTrackingCode, isTrackingCodeUniqueViolation, @@ -6,14 +7,20 @@ import { LicenseChargeTransactionSelect, LicenseChargeTransactionWhereInput, } from '@/generated/prisma/models' +import { PartnersCacheInvalidationService } from '@/modules/partners/cache/partners-cache-invalidation.service' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { BadRequestException, Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' import { ChargeLicenseDto } from './dto/chargedLicenseTransactions.dto' @Injectable() export class PartnerLicenseChargeTransactionService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly cacheInvalidationService: PartnersCacheInvalidationService, + ) {} private readonly TRACKING_CODE_LENGTH = 8 private readonly TRACKING_CODE_MAX_ATTEMPTS = 5 @@ -53,6 +60,18 @@ export class PartnerLicenseChargeTransactionService { } async findAll(partner_id: string, page = 1, perPage = 10) { + const cacheKey = RedisKeyMaker.partnerLicenseChargeTransactionsList( + partner_id, + page, + perPage, + ) + const cached = await this.redisService.getJson<{ data: unknown[]; total: number }>( + cacheKey, + ) + if (cached) { + return ResponseMapper.paginate(cached.data, { page, perPage, total: cached.total }) + } + const defaultWhere: LicenseChargeTransactionWhereInput = { partner_id, } @@ -70,6 +89,7 @@ export class PartnerLicenseChargeTransactionService { ]) const mappedTransactions = transactions.map(this.mappedTransaction) + await this.redisService.setJson(cacheKey, { data: mappedTransactions, total }, 300) return ResponseMapper.paginate(mappedTransactions, { page, @@ -79,6 +99,12 @@ export class PartnerLicenseChargeTransactionService { } async findOne(partner_id: string, id: string) { + const cacheKey = RedisKeyMaker.partnerLicenseChargeTransactionDetail(partner_id, id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const transaction = await this.prisma.licenseChargeTransaction.findUniqueOrThrow({ where: { id, @@ -86,7 +112,9 @@ export class PartnerLicenseChargeTransactionService { }, select: this.defaultSelect, }) - return ResponseMapper.single(this.mappedTransaction(transaction)) + const mappedTransaction = this.mappedTransaction(transaction) + await this.redisService.setJson(cacheKey, mappedTransaction, 300) + return ResponseMapper.single(mappedTransaction) } async create(partner_id: string, data: ChargeLicenseDto) { @@ -134,6 +162,8 @@ export class PartnerLicenseChargeTransactionService { }) this.scheduleLicenseProvisioning(transaction.id, transaction.purchased_count) + await this.cacheInvalidationService.invalidatePartnersList() + await this.cacheInvalidationService.invalidatePartnerLicenses(partner_id) return ResponseMapper.create({ transaction_id: transaction.id, charged_license_count: transaction.purchased_count, diff --git a/src/modules/admin/partners/partners.service.ts b/src/modules/admin/partners/partners.service.ts index f55f128..81a5d3c 100644 --- a/src/modules/admin/partners/partners.service.ts +++ b/src/modules/admin/partners/partners.service.ts @@ -1,6 +1,7 @@ import { UploadedFileTypes } from '@/common/enums/enums' import { translateEnumValue } from '@/common/utils' import { PasswordUtil } from '@/common/utils/password.util' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { AccountStatus, AccountType, @@ -8,8 +9,10 @@ import { PartnerStatus, } from '@/generated/prisma/enums' import { PartnerSelect } from '@/generated/prisma/models' +import { PartnersCacheInvalidationService } from '@/modules/partners/cache/partners-cache-invalidation.service' import { UploaderService } from '@/modules/uploader/uploader.service' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' import { CreatePartnerDto, UpdatePartnerDto } from './dto/partner.dto' @@ -20,6 +23,8 @@ export class PartnersService { constructor( private readonly prisma: PrismaService, private readonly uploaderService: UploaderService, + private readonly redisService: RedisService, + private readonly cacheInvalidationService: PartnersCacheInvalidationService, ) {} private readonly defaultSelect: PartnerSelect = { @@ -137,22 +142,40 @@ export class PartnersService { } async findAll() { + const cacheKey = RedisKeyMaker.partnersList() + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + console.log('cached', cached) + + return ResponseMapper.list(cached) + } + const partners = await this.prisma.partner.findMany({ select: this.defaultSelect, }) const mappedPartners = partners.map(this.mapPartner) + await this.redisService.setJson(cacheKey, mappedPartners, 300) return ResponseMapper.list(mappedPartners) } async findOne(id: string) { + const cacheKey = RedisKeyMaker.partnerDetail(id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const partner = await this.prisma.partner.findUniqueOrThrow({ where: { id }, select: this.defaultSelect, }) - return ResponseMapper.single(this.mapPartner(partner)) + const mappedPartner = this.mapPartner(partner) + await this.redisService.setJson(cacheKey, mappedPartner, 300) + + return ResponseMapper.single(mappedPartner) } async create(data: CreatePartnerDto, logo?: any) { @@ -184,6 +207,8 @@ export class PartnersService { }, select: this.defaultSelect, }) + await this.cacheInvalidationService.invalidatePartnersList() + await this.cacheInvalidationService.invalidatePartnerDetail(partner.id) return ResponseMapper.create(partner) } @@ -220,11 +245,17 @@ export class PartnersService { }) }) + await this.cacheInvalidationService.invalidatePartnersList() + await this.cacheInvalidationService.invalidatePartnerDetail(id) + return ResponseMapper.update(partner) } async delete(id: string) { await this.prisma.partner.delete({ where: { id } }) + await this.cacheInvalidationService.invalidatePartnersList() + await this.cacheInvalidationService.invalidatePartnerDetail(id) + await this.cacheInvalidationService.invalidatePartnerLicenses(id) return ResponseMapper.delete() } } diff --git a/src/modules/consumer/business-activities/business-activities.service.ts b/src/modules/consumer/business-activities/business-activities.service.ts index 735131b..e91cc7c 100644 --- a/src/modules/consumer/business-activities/business-activities.service.ts +++ b/src/modules/consumer/business-activities/business-activities.service.ts @@ -1,6 +1,8 @@ import { BusinessActivitiesQueryService } from '@/common/services/businessActivities/business-activities-query.service' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { BusinessActivitySelect } from '@/generated/prisma/models' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' @@ -10,6 +12,7 @@ export class BusinessActivitiesService { constructor( private readonly prisma: PrismaService, private readonly businessActivitiesQueryService: BusinessActivitiesQueryService, + private readonly redisService: RedisService, ) { this.defaultSelect = { ...this.businessActivitiesQueryService.baseSelect, @@ -37,6 +40,7 @@ export class BusinessActivitiesService { }, } } + private readonly cacheTtlSeconds = 300 private readonly prepareBusinessActivityResponse = (businessActivity: any) => { const { license_activation, complexes, ...rest } = businessActivity @@ -56,20 +60,34 @@ export class BusinessActivitiesService { } async findAll(consumer_id: string) { + const cacheKey = RedisKeyMaker.consumerBusinessActivitiesList(consumer_id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.list(cached) + } + const businessActivities = await this.businessActivitiesQueryService.findAllByConsumer( consumer_id, this.defaultSelect, ) + await this.redisService.setJson(cacheKey, businessActivities, this.cacheTtlSeconds) return ResponseMapper.list(businessActivities) } async findOne(consumer_id: string, id: string) { + const cacheKey = RedisKeyMaker.consumerBusinessActivityInfo(consumer_id, id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const businessActivity = await this.businessActivitiesQueryService.findOneByConsumer( consumer_id, id, this.defaultSelect, ) + await this.redisService.setJson(cacheKey, businessActivity, this.cacheTtlSeconds) return ResponseMapper.single(businessActivity) } @@ -79,6 +97,13 @@ export class BusinessActivitiesService { data, select: this.defaultSelect, }) + await this.redisService.delete( + RedisKeyMaker.consumerBusinessActivityInfo(consumer_id, id), + ) + await this.redisService.delete( + RedisKeyMaker.consumerBusinessActivitiesList(consumer_id), + ) + return ResponseMapper.update(this.prepareBusinessActivityResponse(businessActivity)) } } diff --git a/src/modules/consumer/business-activities/goods/goods.service.ts b/src/modules/consumer/business-activities/goods/goods.service.ts index 89567c3..2a06118 100644 --- a/src/modules/consumer/business-activities/goods/goods.service.ts +++ b/src/modules/consumer/business-activities/goods/goods.service.ts @@ -1,5 +1,6 @@ import { UploadedFileTypes } from '@/common/enums/enums' import { GoodSelect } from '@/generated/prisma/models' +import { PosCacheInvalidationService } from '@/modules/pos/cache/pos-cache-invalidation.service' import { UploaderService } from '@/modules/uploader/uploader.service' import { PrismaService } from '@/prisma/prisma.service' import { Injectable } from '@nestjs/common' @@ -11,6 +12,7 @@ export class ConsumerBusinessActivityGoodsService { constructor( private readonly prisma: PrismaService, private readonly uploaderService: UploaderService, + private readonly posCacheInvalidationService: PosCacheInvalidationService, ) {} defaultSelect: GoodSelect = { @@ -110,6 +112,9 @@ export class ConsumerBusinessActivityGoodsService { }, }, }) + await this.posCacheInvalidationService.invalidateGoodsListByBusinessActivity( + business_activity_id, + ) return ResponseMapper.create(good) } @@ -174,6 +179,9 @@ export class ConsumerBusinessActivityGoodsService { }, }) }) + await this.posCacheInvalidationService.invalidateGoodsListByBusinessActivity( + business_activity_id, + ) return ResponseMapper.update(good) } diff --git a/src/modules/consumer/consumer.service.ts b/src/modules/consumer/consumer.service.ts index 25f3af8..64e2135 100644 --- a/src/modules/consumer/consumer.service.ts +++ b/src/modules/consumer/consumer.service.ts @@ -1,8 +1,10 @@ import { QUERY_CONSTANTS } from '@/common/queryConstants' import { ResponseMapper } from '@/common/response/response-mapper' import { PasswordUtil } from '@/common/utils' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { ConsumerUpdateInput } from '@/generated/prisma/models' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import consumer_mappersUtil from '../../common/utils/mappers/consumer_mappers.util' import { UpdateConsumerInfoDto } from './dto/update-info-request.dto' @@ -10,9 +12,20 @@ import { UpdateConsumerPasswordDto } from './dto/update-password-request.dto' @Injectable() export class ConsumerService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + ) {} + + private readonly infoCacheTtlSeconds = 300 async getInfo(consumer_id: string) { + const cacheKey = RedisKeyMaker.consumerInfo(consumer_id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const consumer = await this.prisma.consumer.findUniqueOrThrow({ where: { id: consumer_id, @@ -24,7 +37,9 @@ export class ConsumerService { }, }) - return ResponseMapper.single(consumer_mappersUtil(consumer)) + const mappedConsumer = consumer_mappersUtil(consumer) + await this.redisService.setJson(cacheKey, mappedConsumer, this.infoCacheTtlSeconds) + return ResponseMapper.single(mappedConsumer) } async updateInfo(consumer_id: string, data: UpdateConsumerInfoDto) { @@ -69,6 +84,22 @@ export class ConsumerService { }, }) + await this.redisService.delete(RedisKeyMaker.consumerInfo(consumer_id)) + const partnerOwner = await this.prisma.consumer.findUnique({ + where: { id: consumer_id }, + select: { + legal: { select: { partner_id: true } }, + individual: { select: { partner_id: true } }, + }, + }) + const partnerId = + partnerOwner?.legal?.partner_id || partnerOwner?.individual?.partner_id || null + if (partnerId) { + await this.redisService.delete( + RedisKeyMaker.partnerConsumerInfo(partnerId, consumer_id), + ) + } + return ResponseMapper.update(consumer) } diff --git a/src/modules/enums/enums.service.ts b/src/modules/enums/enums.service.ts index af14dd1..f8cc9de 100644 --- a/src/modules/enums/enums.service.ts +++ b/src/modules/enums/enums.service.ts @@ -1,5 +1,6 @@ import translates from '@/common/constants/translates/translates' import { translateEnumValue } from '@/common/utils/enum-translator.util' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { AccountStatus, AccountType, @@ -26,12 +27,22 @@ import { TspProviderResponseStatus, TspProviderType, } from '@/generated/prisma/enums' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { GoldKarat, TspProviderCustomerType } from 'common/enums/enums' import { ResponseMapper } from 'common/response/response-mapper' @Injectable() export class EnumsService { + constructor(private readonly redisService: RedisService) {} + + private readonly cacheTtlSeconds = 24 * 60 * 60 + private readonly cacheBuildVersion = + process.env.CACHE_BUILD_VERSION || + process.env.APP_BUILD_ID || + process.env.npm_package_version || + 'local' + private prepareData( items: Record, enumName: keyof typeof translates.enums, @@ -45,7 +56,7 @@ export class EnumsService { }) } - getAllEnums() { + private buildAllEnums() { return { PaymentMethodType: this.prepareData(PaymentMethodType, 'PaymentMethodType'), GoldKarat: this.prepareData(GoldKarat, 'GoldKarat'), @@ -91,8 +102,28 @@ export class EnumsService { } } - getEnumValues(enumName: keyof typeof translates.enums) { - const enums = this.getAllEnums() - return ResponseMapper.list(enums[enumName] || []) + async getAllEnums() { + const cacheKey = RedisKeyMaker.enumsAll(this.cacheBuildVersion) + const cached = await this.redisService.getJson>(cacheKey) + if (cached) { + return cached + } + + const enums = this.buildAllEnums() + await this.redisService.setJson(cacheKey, enums, this.cacheTtlSeconds) + return enums + } + + async getEnumValues(enumName: keyof typeof translates.enums) { + const cacheKey = RedisKeyMaker.enumsValues(this.cacheBuildVersion, enumName) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.list(cached) + } + + const enums = await this.getAllEnums() + const items = enums[enumName] || [] + await this.redisService.setJson(cacheKey, items, this.cacheTtlSeconds) + return ResponseMapper.list(items) } } diff --git a/src/modules/partners/cache/partners-cache-invalidation.service.ts b/src/modules/partners/cache/partners-cache-invalidation.service.ts new file mode 100644 index 0000000..9b1cd3d --- /dev/null +++ b/src/modules/partners/cache/partners-cache-invalidation.service.ts @@ -0,0 +1,167 @@ +import { RedisKeyMaker } from '@/common/utils' +import { RedisService } from '@/redis/redis.service' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class PartnersCacheInvalidationService { + constructor(private readonly redisService: RedisService) {} + + async invalidatePartnerSummary(partnerId: string): Promise { + await this.invalidatePartnersList() + await this.redisService.delete(RedisKeyMaker.partnerDetail(partnerId)) + } + + async invalidatePartnersList(): Promise { + await this.redisService.delete(RedisKeyMaker.partnersList()) + } + + async invalidatePartnerDetail(partnerId: string): Promise { + await this.invalidatePartnerSummary(partnerId) + } + + async invalidatePartnerLicenseReadModels(partnerId: string): Promise { + await this.redisService.deleteByPatterns([ + RedisKeyMaker.partnerActivatedLicensesListPattern(partnerId), + RedisKeyMaker.partnerLicenseChargeTransactionsListPattern(partnerId), + RedisKeyMaker.partnerLicenseChargeTransactionDetailPattern(partnerId), + ]) + } + + async invalidatePartnerLicenses(partnerId: string): Promise { + await this.invalidatePartnerSummary(partnerId) + await this.invalidatePartnerLicenseReadModels(partnerId) + } + + // consumers // + + async invalidatePartnerConsumers( + partnerId: string, + page: number, + perPage: number, + ): Promise { + await this.redisService.delete( + RedisKeyMaker.partnerConsumersList(partnerId, page, perPage), + ) + } + + async invalidatePartnerConsumerReadModels( + partnerId: string, + consumerId: string, + ): Promise { + await this.redisService.deleteByPatterns([ + RedisKeyMaker.partnerConsumersListPattern(partnerId), + RedisKeyMaker.partnerConsumerInfo(partnerId, consumerId), + ]) + } + + async invalidatePartnerConsumerBusinessActivitiesReadModels( + partnerId: string, + consumerId: string, + ): Promise { + await this.invalidatePartnerConsumerReadModels(partnerId, consumerId) + await this.redisService.deleteByPattern( + RedisKeyMaker.partnerConsumerBusinessActivitiesPattern(partnerId, consumerId), + ) + } + + async invalidatePartnerConsumerBusinessActivityReadModels( + partnerId: string, + consumerId: string, + businessActivityId: string, + ): Promise { + await this.invalidatePartnerConsumerBusinessActivitiesReadModels( + partnerId, + consumerId, + ) + await this.redisService.delete( + RedisKeyMaker.partnerConsumerBusinessActivityInfo( + partnerId, + consumerId, + businessActivityId, + ), + ) + } + + async invalidatePartnerConsumerBusinessActivityComplexesReadModels( + partnerId: string, + consumerId: string, + businessActivityId: string, + ): Promise { + await this.invalidatePartnerConsumerBusinessActivitiesReadModels( + partnerId, + consumerId, + ) + await this.redisService.deleteByPattern( + RedisKeyMaker.partnerConsumerBusinessActivityComplexesPattern( + partnerId, + consumerId, + businessActivityId, + ), + ) + } + + async invalidatePartnerConsumerBusinessActivityComplexReadModels( + partnerId: string, + consumerId: string, + businessActivityId: string, + complexId: string, + ): Promise { + await this.invalidatePartnerConsumerBusinessActivityComplexesReadModels( + partnerId, + consumerId, + businessActivityId, + ) + await this.redisService.delete( + RedisKeyMaker.partnerConsumerBusinessActivityComplexInfo( + partnerId, + consumerId, + businessActivityId, + complexId, + ), + ) + } + + async invalidatePartnerConsumerBusinessActivityComplexPosesReadModels( + partnerId: string, + consumerId: string, + businessActivityId: string, + complexId: string, + ): Promise { + await this.invalidatePartnerConsumerBusinessActivitiesReadModels( + partnerId, + consumerId, + ) + await this.redisService.deleteByPattern( + RedisKeyMaker.partnerConsumerBusinessActivityComplexPosesPattern( + partnerId, + consumerId, + businessActivityId, + complexId, + ), + ) + } + + async invalidatePartnerConsumerBusinessActivityComplexPosReadModels( + partnerId: string, + consumerId: string, + businessActivityId: string, + complexId: string, + posId: string, + ): Promise { + await this.invalidatePartnerConsumerBusinessActivityComplexPosesReadModels( + partnerId, + consumerId, + businessActivityId, + complexId, + ) + await this.redisService.delete( + RedisKeyMaker.partnerConsumerBusinessActivityComplexPosInfo( + partnerId, + consumerId, + businessActivityId, + complexId, + posId, + ), + ) + } +} diff --git a/src/modules/partners/consumers/business-activities/business-activities.service.ts b/src/modules/partners/consumers/business-activities/business-activities.service.ts index adcadfb..53f79e8 100644 --- a/src/modules/partners/consumers/business-activities/business-activities.service.ts +++ b/src/modules/partners/consumers/business-activities/business-activities.service.ts @@ -1,13 +1,22 @@ +import { RedisKeyMaker } from '@/common/utils' import { BusinessActivitySelect } from '@/generated/prisma/models' import { getPartnerFirstRemainingLicense } from '@/modules/partners/utils/getPartnerRemainingLicenses.util' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { BadRequestException, Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' +import { PartnersCacheInvalidationService } from '../../cache/partners-cache-invalidation.service' import { CreateBusinessActivitiesDto } from './dto/create-business-activities.dto' @Injectable() export class BusinessActivitiesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly partnersCacheInvalidationService: PartnersCacheInvalidationService, + ) {} + + private readonly cacheTtlSeconds = 300 private readonly setExpireDate = (starts_at: Date) => { return new Date( @@ -85,6 +94,19 @@ export class BusinessActivitiesService { } async findAll(partner_id: string, consumer_id: string, page = 1, perPage = 10) { + const cacheKey = RedisKeyMaker.partnerConsumerBusinessActivitiesList( + partner_id, + consumer_id, + page, + perPage, + ) + const cached = await this.redisService.getJson<{ items: unknown[]; total: number }>( + cacheKey, + ) + if (cached) { + return ResponseMapper.paginate(cached.items, { total: cached.total, page, perPage }) + } + const where = this.defaultWhere(partner_id, consumer_id) const [businessActivities, total] = await this.prisma.$transaction(async tx => [ await tx.businessActivity.findMany({ @@ -95,13 +117,26 @@ export class BusinessActivitiesService { }), await tx.businessActivity.count({ where }), ]) - return ResponseMapper.paginate( - businessActivities.map(this.prepareBusinessActivityResponse), - { total, page, perPage }, + const mappedItems = businessActivities.map(this.prepareBusinessActivityResponse) + await this.redisService.setJson( + cacheKey, + { items: mappedItems, total }, + this.cacheTtlSeconds, ) + return ResponseMapper.paginate(mappedItems, { total, page, perPage }) } async findOne(partner_id: string, consumer_id: string, id: string) { + const cacheKey = RedisKeyMaker.partnerConsumerBusinessActivityInfo( + partner_id, + consumer_id, + id, + ) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const businessActivity = await this.prisma.businessActivity.findUnique({ where: { ...this.defaultWhere(partner_id, consumer_id), @@ -109,7 +144,9 @@ export class BusinessActivitiesService { }, select: this.defaultSelect, }) - return ResponseMapper.single(this.prepareBusinessActivityResponse(businessActivity)) + const mappedItem = this.prepareBusinessActivityResponse(businessActivity) + await this.redisService.setJson(cacheKey, mappedItem, this.cacheTtlSeconds) + return ResponseMapper.single(mappedItem) } async create( @@ -187,6 +224,10 @@ export class BusinessActivitiesService { }) }) + await this.partnersCacheInvalidationService.invalidatePartnerConsumerBusinessActivitiesReadModels( + partner_id, + consumer_id, + ) return ResponseMapper.create(businessActivity) } @@ -196,6 +237,13 @@ export class BusinessActivitiesService { data, select: this.defaultSelect, }) + + await this.partnersCacheInvalidationService.invalidatePartnerConsumerBusinessActivityReadModels( + partner_id, + consumer_id, + id, + ) + return ResponseMapper.update(businessActivity) } diff --git a/src/modules/partners/consumers/business-activities/complexes/complexes.controller.ts b/src/modules/partners/consumers/business-activities/complexes/complexes.controller.ts index ec9d618..1c10a85 100644 --- a/src/modules/partners/consumers/business-activities/complexes/complexes.controller.ts +++ b/src/modules/partners/consumers/business-activities/complexes/complexes.controller.ts @@ -3,7 +3,12 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common' import { ApiTags } from '@nestjs/swagger' import { BusinessActivityComplexesService } from './complexes.service' import { CreateComplexDto, UpdateComplexDto } from './dto/complex.dto' -import type { BusinessActivityComplexesServiceCreateResponseDto, BusinessActivityComplexesServiceFindAllResponseDto, BusinessActivityComplexesServiceFindOneResponseDto, BusinessActivityComplexesServiceUpdateResponseDto } from './dto/complexes-response.dto' +import type { + BusinessActivityComplexesServiceCreateResponseDto, + BusinessActivityComplexesServiceFindAllResponseDto, + BusinessActivityComplexesServiceFindOneResponseDto, + BusinessActivityComplexesServiceUpdateResponseDto, +} from './dto/complexes-response.dto' @ApiTags('PartnerBusinessActivityComplexes') @Controller( @@ -34,10 +39,11 @@ export class BusinessActivityComplexesController { @Post() async create( @PartnerInfo('id') partnerId: string, + @Param('consumerId') consumerId: string, @Param('businessActivityId') businessActivityId: string, @Body() data: CreateComplexDto, ): Promise { - return this.service.create(partnerId, businessActivityId, data) + return this.service.create(partnerId, consumerId, businessActivityId, data) } @Patch(':id') diff --git a/src/modules/partners/consumers/business-activities/complexes/complexes.service.ts b/src/modules/partners/consumers/business-activities/complexes/complexes.service.ts index ae35d5e..4afeb7c 100644 --- a/src/modules/partners/consumers/business-activities/complexes/complexes.service.ts +++ b/src/modules/partners/consumers/business-activities/complexes/complexes.service.ts @@ -1,13 +1,22 @@ import { QUERY_CONSTANTS } from '@/common/queryConstants' +import { RedisKeyMaker } from '@/common/utils' import { ComplexSelect, ComplexWhereInput } from '@/generated/prisma/models' +import { PartnersCacheInvalidationService } from '@/modules/partners/cache/partners-cache-invalidation.service' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { BadRequestException, Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' import { CreateComplexDto, UpdateComplexDto } from './dto/complex.dto' @Injectable() export class BusinessActivityComplexesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly partnersCacheInvalidationService: PartnersCacheInvalidationService, + ) {} + + private readonly cacheTtlSeconds = 300 private readonly defaultSelect: ComplexSelect = { id: true, @@ -49,6 +58,20 @@ export class BusinessActivityComplexesService { page = 1, perPage = 10, ) { + const cacheKey = RedisKeyMaker.partnerConsumerBusinessActivityComplexesList( + partner_id, + consumer_id, + business_activity_id, + page, + perPage, + ) + const cached = await this.redisService.getJson<{ items: unknown[]; total: number }>( + cacheKey, + ) + if (cached) { + return ResponseMapper.paginate(cached.items, { total: cached.total, page, perPage }) + } + const where = this.defaultWhere(partner_id, consumer_id, business_activity_id) const [complexes, total] = await this.prisma.$transaction(async tx => [ await tx.complex.findMany({ @@ -74,6 +97,11 @@ export class BusinessActivityComplexesService { pos_count: _count.pos_list, } }) + await this.redisService.setJson( + cacheKey, + { items: mappedComplexes, total }, + this.cacheTtlSeconds, + ) return ResponseMapper.paginate(mappedComplexes, { total, page, perPage }) } @@ -83,18 +111,40 @@ export class BusinessActivityComplexesService { business_activity_id: string, id: string, ) { - const complex = await this.prisma.complex.findUnique({ + const cacheKey = RedisKeyMaker.partnerConsumerBusinessActivityComplexInfo( + partner_id, + consumer_id, + business_activity_id, + id, + ) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + + const complex = await this.prisma.complex.findFirst({ where: { ...this.defaultWhere(partner_id, consumer_id, business_activity_id), id, }, select: this.defaultSelect, }) + + if (!complex) { + throw new BadRequestException('شعبه مورد نظر یافت نشد.') + } + + await this.redisService.setJson(cacheKey, complex, this.cacheTtlSeconds) return ResponseMapper.single(complex) } - async create(partner_id: string, business_id: string, data: CreateComplexDto) { - const complex = this.prisma.$transaction(async tx => { + async create( + partner_id: string, + business_id: string, + consumer_id: string, + data: CreateComplexDto, + ) { + const complex = await this.prisma.$transaction(async tx => { const relatedLicense = await tx.licenseAccountAllocation.findFirst({ where: { license_activation: { @@ -121,7 +171,7 @@ export class BusinessActivityComplexesService { ) } - return await this.prisma.complex.create({ + return await tx.complex.create({ data: { ...data, business_activity: { @@ -133,6 +183,12 @@ export class BusinessActivityComplexesService { }) }) + await this.partnersCacheInvalidationService.invalidatePartnerConsumerBusinessActivityComplexesReadModels( + partner_id, + consumer_id, + business_id, + ) + return ResponseMapper.create(complex) } @@ -147,6 +203,13 @@ export class BusinessActivityComplexesService { where: { ...this.defaultWhere(partner_id, consumer_id, business_activity_id), id }, data, }) + + await this.partnersCacheInvalidationService.invalidatePartnerConsumerBusinessActivityComplexReadModels( + partner_id, + consumer_id, + business_activity_id, + id, + ) return ResponseMapper.update(complex) } diff --git a/src/modules/partners/consumers/business-activities/complexes/poses/poses.service.ts b/src/modules/partners/consumers/business-activities/complexes/poses/poses.service.ts index c2cb463..d443866 100644 --- a/src/modules/partners/consumers/business-activities/complexes/poses/poses.service.ts +++ b/src/modules/partners/consumers/business-activities/complexes/poses/poses.service.ts @@ -1,7 +1,7 @@ import { QUERY_CONSTANTS } from '@/common/queryConstants' import { consumerRelatedPartner } from '@/common/queryConstants/consumer' -import { PrismaErrorUtil } from '@/common/utils/prisma-error.util' import { PasswordUtil } from '@/common/utils/password.util' +import { PrismaErrorUtil } from '@/common/utils/prisma-error.util' import { AccountStatus, AccountType, @@ -9,14 +9,22 @@ import { POSStatus, } from '@/generated/prisma/enums' import { PosSelect } from '@/generated/prisma/models' +import { PartnersCacheInvalidationService } from '@/modules/partners/cache/partners-cache-invalidation.service' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { BadRequestException, Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' import { CreatePosDto, UpdatePosDto } from './dto/pos.dto' @Injectable() export class ComplexPosesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly partnersCacheInvalidationService: PartnersCacheInvalidationService, + ) {} + + private readonly cacheTtlSeconds = 300 private readonly defaultSelect: PosSelect = { id: true, diff --git a/src/modules/partners/consumers/consumers.service.ts b/src/modules/partners/consumers/consumers.service.ts index 828e83a..690b17b 100644 --- a/src/modules/partners/consumers/consumers.service.ts +++ b/src/modules/partners/consumers/consumers.service.ts @@ -1,6 +1,7 @@ import { QUERY_CONSTANTS } from '@/common/queryConstants' import mapConsumerWithLicenseUtil from '@/common/utils/mappers/consumer_mappers.util' import { PasswordUtil } from '@/common/utils/password.util' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { AccountStatus, AccountType, @@ -15,13 +16,21 @@ import { ConsumerWhereInput, } from '@/generated/prisma/models' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { BadRequestException, Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' +import { PartnersCacheInvalidationService } from '../cache/partners-cache-invalidation.service' import { CreateConsumerDto, UpdateConsumerDto } from './dto/create-consumers.dto' @Injectable() export class PartnerConsumersService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + private readonly partnersCacheInvalidationService: PartnersCacheInvalidationService, + ) {} + + private readonly infoCacheTtlSeconds = 300 private readonly setExpireDate = (starts_at: Date) => { return new Date( @@ -54,6 +63,14 @@ export class PartnerConsumersService { }) async findAll(partner_id: string, page = 1, perPage = 10) { + const cacheKey = RedisKeyMaker.partnerConsumersList(partner_id, page, perPage) + const cached = await this.redisService.getJson<{ items: unknown[]; total: number }>( + cacheKey, + ) + if (cached) { + return ResponseMapper.paginate(cached.items, { total: cached.total, page, perPage }) + } + const [consumers, total] = await this.prisma.$transaction(async tx => [ await tx.consumer.findMany({ where: this.defaultWhere(partner_id), @@ -65,7 +82,13 @@ export class PartnerConsumersService { where: this.defaultWhere(partner_id), }), ]) - return ResponseMapper.paginate(consumers.map(mapConsumerWithLicenseUtil), { + const mappedItems = consumers.map(mapConsumerWithLicenseUtil) + await this.redisService.setJson( + cacheKey, + { items: mappedItems, total }, + this.infoCacheTtlSeconds, + ) + return ResponseMapper.paginate(mappedItems, { total, page, perPage, @@ -73,6 +96,12 @@ export class PartnerConsumersService { } async findOne(partner_id: string, consumer_id: string) { + const cacheKey = RedisKeyMaker.partnerConsumerInfo(partner_id, consumer_id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const consumer = await this.prisma.consumer.findUniqueOrThrow({ where: { ...(this.defaultWhere(partner_id) as any), @@ -81,7 +110,14 @@ export class PartnerConsumersService { select: this.defaultSelect, }) - return ResponseMapper.single(mapConsumerWithLicenseUtil(consumer)) + const mappedConsumer = mapConsumerWithLicenseUtil(consumer) + await this.redisService.setJson( + RedisKeyMaker.consumerInfo(consumer_id), + mappedConsumer, + this.infoCacheTtlSeconds, + ) + await this.redisService.setJson(cacheKey, mappedConsumer, this.infoCacheTtlSeconds) + return ResponseMapper.single(mappedConsumer) } async create(partner_id: string, data: CreateConsumerDto) { @@ -224,11 +260,16 @@ export class PartnerConsumersService { }) }) + await this.partnersCacheInvalidationService.invalidatePartnerConsumerReadModels( + partner_id, + consumer.id, + ) + return ResponseMapper.single(mapConsumerWithLicenseUtil(consumer)) } async update(id: string, partner_id: string, data: UpdateConsumerDto) { - return this.prisma.consumer.update({ + const updatedConsumer = await this.prisma.consumer.update({ where: { ...(this.defaultWhere(partner_id) as any), id, @@ -243,5 +284,13 @@ export class PartnerConsumersService { }, }, }) + + await this.partnersCacheInvalidationService.invalidatePartnerConsumerReadModels( + partner_id, + id, + ) + await this.redisService.delete(RedisKeyMaker.consumerInfo(id)) + + return updatedConsumer } } diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts index 5754c93..dfebe9c 100644 --- a/src/modules/partners/partners.service.ts +++ b/src/modules/partners/partners.service.ts @@ -1,6 +1,7 @@ import { UploadedFileTypes } from '@/common/enums/enums' import { ResponseMapper } from '@/common/response/response-mapper' import { PasswordUtil } from '@/common/utils' +import { PartnersCacheInvalidationService } from '@/modules/partners/cache/partners-cache-invalidation.service' import { UploaderService } from '@/modules/uploader/uploader.service' import { PrismaService } from '@/prisma/prisma.service' import { Injectable } from '@nestjs/common' @@ -12,6 +13,7 @@ export class PartnerService { constructor( private readonly prisma: PrismaService, private readonly uploaderService: UploaderService, + private readonly cacheInvalidationService: PartnersCacheInvalidationService, ) {} async me(partner_id: string, account_id: string) { @@ -245,6 +247,9 @@ export class PartnerService { }) }) + await this.cacheInvalidationService.invalidatePartnersList() + await this.cacheInvalidationService.invalidatePartnerDetail(partner_id) + return ResponseMapper.single(updatedPartner) } diff --git a/src/modules/pos/cache/pos-cache-invalidation.service.ts b/src/modules/pos/cache/pos-cache-invalidation.service.ts new file mode 100644 index 0000000..7aa800a --- /dev/null +++ b/src/modules/pos/cache/pos-cache-invalidation.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common' +import { RedisService } from '@/redis/redis.service' + +@Injectable() +export class PosCacheInvalidationService { + constructor(private readonly redisService: RedisService) {} + + async invalidateGoodsListByGuild(guildId: string): Promise { + const client = await this.redisService.getClient() + const keys = await client.keys(`pos:ba:*:guild:${guildId}:goods:list`) + if (keys.length > 0) { + await client.del(keys) + } + } + + async invalidateGoodsListByBusinessActivity( + businessActivityId: string, + ): Promise { + const client = await this.redisService.getClient() + const keys = await client.keys(`pos:ba:${businessActivityId}:guild:*:goods:list`) + if (keys.length > 0) { + await client.del(keys) + } + } +} diff --git a/src/modules/pos/goods/dto/update-good.dto.ts b/src/modules/pos/goods/dto/update-good.dto.ts new file mode 100644 index 0000000..70b32e8 --- /dev/null +++ b/src/modules/pos/goods/dto/update-good.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger' +import { CreateGoodDto } from './create-good.dto' + +export class UpdateGoodDto extends PartialType(CreateGoodDto) {} diff --git a/src/modules/pos/goods/goods.service.ts b/src/modules/pos/goods/goods.service.ts index de8a7ff..20bd176 100644 --- a/src/modules/pos/goods/goods.service.ts +++ b/src/modules/pos/goods/goods.service.ts @@ -1,12 +1,22 @@ +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { GoodSelect } from '@/generated/prisma/models' +import { PosCacheInvalidationService } from '@/modules/pos/cache/pos-cache-invalidation.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from '../../../common/response/response-mapper' import { PrismaService } from '../../../prisma/prisma.service' import { CreateGoodDto } from './dto/create-good.dto' +import { UpdateGoodDto } from './dto/update-good.dto' @Injectable() export class GoodsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private readonly redisService: RedisService, + private readonly posCacheInvalidationService: PosCacheInvalidationService, + ) {} + + private readonly listCacheTtlSeconds = 300 private readonly defaultSelect: GoodSelect = { id: true, @@ -42,6 +52,12 @@ export class GoodsService { } async findAll(business_activity_id: string, guild_id: string) { + const cacheKey = RedisKeyMaker.posGoodsList(business_activity_id, guild_id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.list(cached) + } + const goods = await this.prisma.good.findMany({ where: { OR: [ @@ -59,6 +75,8 @@ export class GoodsService { select: this.defaultSelect, }) + await this.redisService.setJson(cacheKey, goods, this.listCacheTtlSeconds) + return ResponseMapper.list(goods) } @@ -117,6 +135,73 @@ export class GoodsService { select: this.defaultSelect, }) + await this.posCacheInvalidationService.invalidateGoodsListByBusinessActivity( + business_activity_id, + ) + return ResponseMapper.create(good) } + + async update(id: string, data: UpdateGoodDto, business_activity_id: string) { + const { category_id, sku_id, measure_unit_id, ...rest } = data + + const good = await this.prisma.good.update({ + where: { + id, + business_activity_id, + }, + data: { + ...rest, + ...(measure_unit_id + ? { + measure_unit: { + connect: { + id: measure_unit_id, + }, + }, + } + : {}), + ...(sku_id + ? { + sku: { + connect: { + id: sku_id, + }, + }, + } + : {}), + ...(category_id + ? { + category: { + connect: { + id: category_id, + }, + }, + } + : {}), + }, + select: this.defaultSelect, + }) + + await this.posCacheInvalidationService.invalidateGoodsListByBusinessActivity( + business_activity_id, + ) + + return ResponseMapper.update(good) + } + + async delete(id: string, business_activity_id: string) { + await this.prisma.good.delete({ + where: { + id, + business_activity_id, + }, + }) + + await this.posCacheInvalidationService.invalidateGoodsListByBusinessActivity( + business_activity_id, + ) + + return ResponseMapper.delete() + } } diff --git a/src/modules/pos/pos.service.ts b/src/modules/pos/pos.service.ts index 7c15388..d8ec164 100644 --- a/src/modules/pos/pos.service.ts +++ b/src/modules/pos/pos.service.ts @@ -1,15 +1,29 @@ import { QUERY_CONSTANTS } from '@/common/queryConstants' import consumer_mappersUtil from '@/common/utils/mappers/consumer_mappers.util' +import { RedisKeyMaker } from '@/common/utils/redisKeyMaker' import { ConsumerStatus } from '@/generated/prisma/enums' import { PrismaService } from '@/prisma/prisma.service' +import { RedisService } from '@/redis/redis.service' import { Injectable } from '@nestjs/common' import { ResponseMapper } from 'common/response/response-mapper' @Injectable() export class PosService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly redisService: RedisService, + ) {} + + private readonly infoCacheTtlSeconds = 300 + private readonly meCacheTtlSeconds = 120 async getInfo(pos_id: string) { + const cacheKey = RedisKeyMaker.posInfo(pos_id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const pos = await this.prisma.pos.findUniqueOrThrow({ where: { id: pos_id, @@ -71,7 +85,7 @@ export class PosService { license_activation, } = business_activity - return ResponseMapper.single({ + const payload = { name, complex: { id: complexId, @@ -89,7 +103,9 @@ export class PosService { license_id: license_activation?.license.id, }, partner: license_activation?.license.charge_transaction.partner, - }) + } + await this.redisService.setJson(cacheKey, payload, this.infoCacheTtlSeconds) + return ResponseMapper.single(payload) } async getAccessible(account_id: string) { @@ -136,6 +152,12 @@ export class PosService { } async getMe(account_id: string, pos_id: string) { + const cacheKey = RedisKeyMaker.posMe(account_id, pos_id) + const cached = await this.redisService.getJson(cacheKey) + if (cached) { + return ResponseMapper.single(cached) + } + const pos = await this.prisma.consumerAccount.findUniqueOrThrow({ where: { id: account_id, @@ -177,9 +199,11 @@ export class PosService { const { consumer, ...rest } = pos - return ResponseMapper.single({ + const payload = { ...rest, consumer: consumer_mappersUtil(consumer), - }) + } + await this.redisService.setJson(cacheKey, payload, this.meCacheTtlSeconds) + return ResponseMapper.single(payload) } } diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts index 63a215d..fadb34d 100644 --- a/src/redis/redis.module.ts +++ b/src/redis/redis.module.ts @@ -1,9 +1,22 @@ import { Global, Module } from '@nestjs/common' +import { AdminGuildCacheInvalidationService } from '@/modules/admin/guilds/cache/admin-guild-cache-invalidation.service' +import { PartnersCacheInvalidationService } from '@/modules/partners/cache/partners-cache-invalidation.service' +import { PosCacheInvalidationService } from '@/modules/pos/cache/pos-cache-invalidation.service' import { RedisService } from './redis.service' @Global() @Module({ - providers: [RedisService], - exports: [RedisService], + providers: [ + RedisService, + AdminGuildCacheInvalidationService, + PartnersCacheInvalidationService, + PosCacheInvalidationService, + ], + exports: [ + RedisService, + AdminGuildCacheInvalidationService, + PartnersCacheInvalidationService, + PosCacheInvalidationService, + ], }) export class RedisModule {} diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index a401098..65b3ec8 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -34,6 +34,99 @@ export class RedisService implements OnModuleDestroy { return this.client } + async get(key: string): Promise { + const client = await this.getClient() + return client.get(key) + } + + async getJson(key: string): Promise { + const value = await this.get(key) + if (!value) { + return null + } + + try { + return JSON.parse(value) as T + } catch { + this.logger.warn(`Invalid JSON for key "${key}"`) + return null + } + } + + async set( + key: string, + value: string, + ttlSeconds?: number, + ): Promise<'OK' | null> { + const client = await this.getClient() + if (ttlSeconds && ttlSeconds > 0) { + return client.set(key, value, 'EX', ttlSeconds) + } + return client.set(key, value) + } + + async setJson( + key: string, + value: unknown, + ttlSeconds?: number, + ): Promise<'OK' | null> { + return this.set(key, JSON.stringify(value), ttlSeconds) + } + + async delete(key: string): Promise { + const client = await this.getClient() + return client.del(key) + } + + async exists(key: string): Promise { + const client = await this.getClient() + const result = await client.exists(key) + return result === 1 + } + + async expire(key: string, ttlSeconds: number): Promise { + const client = await this.getClient() + const result = await client.expire(key, ttlSeconds) + return result === 1 + } + + async deleteByPattern(pattern: string): Promise { + const client = await this.getClient() + let deletedCount = 0 + let cursor = '0' + + do { + const [nextCursor, keys] = await client.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + 100, + ) + cursor = nextCursor + + if (keys.length > 0) { + const pipeline = client.pipeline() + keys.forEach(k => pipeline.del(k)) + const results = await pipeline.exec() + if (results) { + deletedCount += results.reduce((sum, [, value]) => { + return sum + (typeof value === 'number' ? value : 0) + }, 0) + } + } + } while (cursor !== '0') + + return deletedCount + } + + async deleteByPatterns(patterns: string[]): Promise { + const deletedCounts = await Promise.all( + patterns.map(pattern => this.deleteByPattern(pattern)), + ) + return deletedCounts.reduce((sum, count) => sum + count, 0) + } + async onModuleDestroy() { await this.client.quit() }