feat: 优化微信订阅消息体系并新增约课取消通知与开课前1小时提醒
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -96,6 +96,11 @@
|
|||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Subscribe note -->
|
||||||
|
<view class="subscribe-tip">
|
||||||
|
<text class="subscribe-tip-text">🔔 确认预约将同步订阅约课结果、课前1小时提醒与取消通知</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<view class="action-row">
|
<view class="action-row">
|
||||||
<view class="btn-outline" @tap="handleCancel">
|
<view class="btn-outline" @tap="handleCancel">
|
||||||
@@ -210,6 +215,8 @@ function handleMaskTap() {
|
|||||||
.no-card-text { font-size: 24rpx; color: #8b817b; }
|
.no-card-text { font-size: 24rpx; color: #8b817b; }
|
||||||
.deduction-tip { padding: 18rpx 4rpx; }
|
.deduction-tip { padding: 18rpx 4rpx; }
|
||||||
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
|
.deduction-text { font-size: 22rpx; color: #8b817b; line-height: 1.6; }
|
||||||
|
.subscribe-tip { padding: 0 4rpx 14rpx; text-align: center; }
|
||||||
|
.subscribe-tip-text { font-size: 21rpx; color: #7f8a7e; }
|
||||||
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
|
.action-row { display: flex; gap: 20rpx; margin-top: 12rpx; }
|
||||||
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
|
.btn-outline { flex: 1; height: 88rpx; border-radius: 999rpx; background: #f0eae4; display: flex; align-items: center; justify-content: center; }
|
||||||
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
|
.btn-outline-text { font-size: 28rpx; color: #78675c; font-weight: 400; }
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface MenuItem {
|
|||||||
title?: string
|
title?: string
|
||||||
path?: string
|
path?: string
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
action?: 'clear'
|
action?: 'clear' | 'notifications'
|
||||||
requireAuth?: boolean
|
requireAuth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'clear-cache'): void
|
(e: 'clear-cache'): void
|
||||||
(e: 'require-login'): void
|
(e: 'require-login'): void
|
||||||
|
(e: 'open-notifications'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const menuItems = computed<MenuItem[]>(() => {
|
const menuItems = computed<MenuItem[]>(() => {
|
||||||
@@ -64,6 +65,13 @@ const menuItems = computed<MenuItem[]>(() => {
|
|||||||
path: '/pages/profile/info',
|
path: '/pages/profile/info',
|
||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'notifications',
|
||||||
|
type: 'item',
|
||||||
|
title: '消息提醒设置',
|
||||||
|
action: 'notifications',
|
||||||
|
requireAuth: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'sep1',
|
key: 'sep1',
|
||||||
type: 'separator',
|
type: 'separator',
|
||||||
@@ -98,6 +106,8 @@ function handleTap(item: MenuItem) {
|
|||||||
}
|
}
|
||||||
if (item.action === 'clear') {
|
if (item.action === 'clear') {
|
||||||
emit('clear-cache')
|
emit('clear-cache')
|
||||||
|
} else if (item.action === 'notifications') {
|
||||||
|
emit('open-notifications')
|
||||||
} else if (item.path) {
|
} else if (item.path) {
|
||||||
uni.navigateTo({ url: item.path })
|
uni.navigateTo({ url: item.path })
|
||||||
}
|
}
|
||||||
|
|||||||
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal file
376
packages/app/src/components/SubscriptionSettingsModal.vue
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="visible" class="modal-mask" @tap="handleClose">
|
||||||
|
<view class="modal-panel" @tap.stop>
|
||||||
|
<view class="modal-header">
|
||||||
|
<text class="modal-title">微信消息提醒设置</text>
|
||||||
|
<view class="close-btn" @tap="handleClose">
|
||||||
|
<text class="close-icon">✕</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="notice-box">
|
||||||
|
<text class="notice-title">💡 为什么需要增加订阅次数?</text>
|
||||||
|
<text class="notice-desc">
|
||||||
|
微信订阅消息每授权 1 次可接收 1 条通知。建议点击下方按钮,并在弹出的微信授权窗中勾选<text class="notice-highlight">「总是保持以上选择」</text>,即可永久无感自动接收课程变动与上课提醒。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="quota-list">
|
||||||
|
<view v-if="loading" class="loading-wrap">
|
||||||
|
<text class="loading-text">加载提醒状态中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="quotas.length === 0" class="empty-wrap">
|
||||||
|
<text class="empty-text">未检测到可用提醒模板配置</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else v-for="item in quotas" :key="item.scene" class="quota-item">
|
||||||
|
<view class="quota-info">
|
||||||
|
<view class="quota-title-row">
|
||||||
|
<text class="quota-icon">{{ getSceneIcon(item.scene) }}</text>
|
||||||
|
<text class="quota-name">{{ getSceneName(item.scene) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="quota-desc">{{ item.description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="quota-badge" :class="getBadgeClass(item.remainingQuota)">
|
||||||
|
<text class="badge-text">
|
||||||
|
{{ item.remainingQuota > 0 ? `余 ${item.remainingQuota} 次` : '待补充' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="modal-actions">
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:loading="subscribing"
|
||||||
|
:disabled="subscribing"
|
||||||
|
@tap="handleTopUp"
|
||||||
|
>
|
||||||
|
一键补充全部提醒次数 (+3)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<view class="btn-secondary" @tap="handleOpenSettings">
|
||||||
|
<text class="btn-secondary-text">微信权限与通知设置</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { SubscriptionMessageScene } from '@mp-pilates/shared'
|
||||||
|
import type { SubscriptionQuotaItem } from '@mp-pilates/shared'
|
||||||
|
import {
|
||||||
|
fetchUserSubscriptionQuotas,
|
||||||
|
requestBookingBundleSubscriptionMessage,
|
||||||
|
openSubscribeSettings,
|
||||||
|
} from '../utils/wechat-subscription'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', val: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const quotas = ref<SubscriptionQuotaItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const subscribing = ref(false)
|
||||||
|
|
||||||
|
async function loadQuotas() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
quotas.value = await fetchUserSubscriptionQuotas()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
void loadQuotas()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSceneIcon(scene: SubscriptionMessageScene): string {
|
||||||
|
switch (scene) {
|
||||||
|
case SubscriptionMessageScene.BOOKING_CREATED:
|
||||||
|
return '📅'
|
||||||
|
case SubscriptionMessageScene.CLASS_REMINDER:
|
||||||
|
return '⏰'
|
||||||
|
case SubscriptionMessageScene.BOOKING_CANCELLED:
|
||||||
|
return '📋'
|
||||||
|
case SubscriptionMessageScene.CLASS_REVIEW:
|
||||||
|
return '⭐'
|
||||||
|
default:
|
||||||
|
return '🔔'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSceneName(scene: SubscriptionMessageScene): string {
|
||||||
|
switch (scene) {
|
||||||
|
case SubscriptionMessageScene.BOOKING_CREATED:
|
||||||
|
return '约课成功通知'
|
||||||
|
case SubscriptionMessageScene.CLASS_REMINDER:
|
||||||
|
return '上课前 1 小时提醒'
|
||||||
|
case SubscriptionMessageScene.BOOKING_CANCELLED:
|
||||||
|
return '约课取消通知'
|
||||||
|
case SubscriptionMessageScene.CLASS_REVIEW:
|
||||||
|
return '课后评价提醒'
|
||||||
|
default:
|
||||||
|
return '课程服务通知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadgeClass(quota: number): string {
|
||||||
|
if (quota >= 3) return 'badge--healthy'
|
||||||
|
if (quota > 0) return 'badge--warning'
|
||||||
|
return 'badge--danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTopUp() {
|
||||||
|
if (subscribing.value) return
|
||||||
|
subscribing.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await requestBookingBundleSubscriptionMessage()
|
||||||
|
const acceptedCount = results.filter((r) => r.result === 'accept').length
|
||||||
|
if (acceptedCount > 0) {
|
||||||
|
uni.showToast({ title: `已成功补充 ${acceptedCount} 项提醒额度`, icon: 'success' })
|
||||||
|
await loadQuotas()
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '未增加额度,可再次点击尝试', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[subscribe] top-up failed', error)
|
||||||
|
uni.showToast({ title: '授权未完成,可进入微信设置检查', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
subscribing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOpenSettings() {
|
||||||
|
await openSubscribeSettings()
|
||||||
|
await loadQuotas()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(56, 48, 42, 0.45);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fbf9f6;
|
||||||
|
border-radius: 36rpx 36rpx 0 0;
|
||||||
|
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||||
|
max-height: 85vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #514943;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-box {
|
||||||
|
background: #f0f4ee;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #476d54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-desc {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #657568;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-highlight {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3b5a45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
max-height: 480rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap,
|
||||||
|
.empty-wrap {
|
||||||
|
padding: 40rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.empty-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border: 2rpx solid #eee8e3;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-icon {
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #514943;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-desc {
|
||||||
|
font-size: 21rpx;
|
||||||
|
color: #8b817b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-badge {
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--healthy {
|
||||||
|
background: #edf5eb;
|
||||||
|
.badge-text {
|
||||||
|
color: #4f7957;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--warning {
|
||||||
|
background: #fdf5ea;
|
||||||
|
.badge-text {
|
||||||
|
color: #b07d39;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--danger {
|
||||||
|
background: #fbeee9;
|
||||||
|
.badge-text {
|
||||||
|
color: #bc5e4c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #6b8276;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
line-height: 1;
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background: #597264;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary-text {
|
||||||
|
font-size: 25rpx;
|
||||||
|
color: #7b8974;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -90,6 +90,9 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<ClassReviewForm v-if="booking && booking.userId === userStore.user?.id && booking.status === BookingStatus.COMPLETED" :key="booking.id" :booking-id="booking.id" />
|
<ClassReviewForm v-if="booking && booking.userId === userStore.user?.id && booking.status === BookingStatus.COMPLETED" :key="booking.id" :booking-id="booking.id" />
|
||||||
|
<view v-if="canSubscribeClassReminder" class="panel">
|
||||||
|
<button class="review-subscribe" @tap="subscribeClassReminder">🔔 订阅开课前 1 小时微信提醒</button>
|
||||||
|
</view>
|
||||||
<view v-if="canSubscribeReview" class="panel">
|
<view v-if="canSubscribeReview" class="panel">
|
||||||
<button class="review-subscribe" @tap="subscribeReview">{{ booking?.status === BookingStatus.COMPLETED ? '如果现在不评,24 小时后提醒我' : '订阅课后评价提醒' }}</button>
|
<button class="review-subscribe" @tap="subscribeReview">{{ booking?.status === BookingStatus.COMPLETED ? '如果现在不评,24 小时后提醒我' : '订阅课后评价提醒' }}</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -222,10 +225,35 @@ import CustomNavBar from '../../components/CustomNavBar.vue'
|
|||||||
import ClassReviewForm from '../../components/ClassReviewForm.vue'
|
import ClassReviewForm from '../../components/ClassReviewForm.vue'
|
||||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||||
|
|
||||||
import { requestSubscriptionMessage } from '../../utils/wechat-subscription'
|
import {
|
||||||
|
requestSubscriptionMessage,
|
||||||
|
requestBookingCancelSubscriptionMessage,
|
||||||
|
requestClassReminderSubscriptionMessage,
|
||||||
|
cacheSubscriptionMessageTemplateConfig,
|
||||||
|
} from '../../utils/wechat-subscription'
|
||||||
import { get } from '../../utils/request'
|
import { get } from '../../utils/request'
|
||||||
import { cacheSubscriptionMessageTemplateConfig } from '../../utils/wechat-subscription'
|
|
||||||
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
|
import type { SubscriptionMessageTemplateConfig } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
const canSubscribeClassReminder = computed(() => {
|
||||||
|
if (!booking.value || booking.value.status !== BookingStatus.CONFIRMED) return false
|
||||||
|
if (booking.value.userId !== userStore.user?.id) return false
|
||||||
|
const slot = booking.value.timeSlot
|
||||||
|
if (!slot) return false
|
||||||
|
return !isSlotPast(slot.date, slot.startTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function subscribeClassReminder() {
|
||||||
|
try {
|
||||||
|
const results = await requestClassReminderSubscriptionMessage()
|
||||||
|
uni.showToast({
|
||||||
|
title: results.some((r) => r.result === 'accept') ? '上课提醒已开启' : '暂未开启提醒',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function subscribeReview() {
|
async function subscribeReview() {
|
||||||
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
|
try { const results = await requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REVIEW); uni.showToast({ title: results.some(r => r.result === 'accept') ? '提醒已开启' : '暂未开启提醒', icon: 'none' }) } catch { uni.showToast({ title: '订阅失败,请稍后重试', icon: 'none' }) }
|
||||||
}
|
}
|
||||||
@@ -606,6 +634,12 @@ async function handleNoShow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel() {
|
async function handleCancel() {
|
||||||
|
try {
|
||||||
|
await requestBookingCancelSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||||
|
}
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '取消预约',
|
title: '取消预约',
|
||||||
content: '确定要取消该预约?',
|
content: '确定要取消该预约?',
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ import DateSelector from '../../components/DateSelector.vue'
|
|||||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||||
import SlotCard from '../../components/SlotCard.vue'
|
import SlotCard from '../../components/SlotCard.vue'
|
||||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||||
|
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
|
|
||||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||||
|
|
||||||
@@ -368,6 +369,12 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
|
|||||||
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
||||||
if (!slot.myBookingId) return
|
if (!slot.myBookingId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestBookingCancelSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||||
|
}
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '取消预约',
|
title: '取消预约',
|
||||||
content: '确定要取消这个预约吗?',
|
content: '确定要取消这个预约吗?',
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ import {
|
|||||||
} from '../../utils/booking-helpers'
|
} from '../../utils/booking-helpers'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
||||||
|
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
|
||||||
|
|
||||||
type TabKey = 'upcoming' | 'history'
|
type TabKey = 'upcoming' | 'history'
|
||||||
|
|
||||||
@@ -327,6 +328,12 @@ function goDetail(booking: BookingWithDetails) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancel(booking: BookingWithDetails) {
|
async function handleCancel(booking: BookingWithDetails) {
|
||||||
|
try {
|
||||||
|
await requestBookingCancelSubscriptionMessage()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||||||
|
}
|
||||||
|
|
||||||
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
||||||
const timeLabel = startTime(booking)
|
const timeLabel = startTime(booking)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
:require-auth="loggedIn"
|
:require-auth="loggedIn"
|
||||||
@clear-cache="handleClearCache"
|
@clear-cache="handleClearCache"
|
||||||
@require-login="handleLogin"
|
@require-login="handleLogin"
|
||||||
|
@open-notifications="showNotificationsModal = true"
|
||||||
>
|
>
|
||||||
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
|
||||||
</ProfileMenu>
|
</ProfileMenu>
|
||||||
@@ -25,6 +26,9 @@
|
|||||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||||
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
|
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- Notification Settings Modal -->
|
||||||
|
<SubscriptionSettingsModal v-model:visible="showNotificationsModal" />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -40,11 +44,13 @@ import { getErrorMessage } from '../../utils/auth'
|
|||||||
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
|
||||||
import UserCard from '../../components/UserCard.vue'
|
import UserCard from '../../components/UserCard.vue'
|
||||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||||
|
import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
|
||||||
|
|
||||||
const invite = useInviteStore()
|
const invite = useInviteStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
|
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
|
||||||
|
|
||||||
|
const showNotificationsModal = ref(false)
|
||||||
const activityRefreshKey = ref(0)
|
const activityRefreshKey = ref(0)
|
||||||
const membershipNow = ref(Date.now())
|
const membershipNow = ref(Date.now())
|
||||||
const loginLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import type {
|
|||||||
SubscriptionMessageRequestItem,
|
SubscriptionMessageRequestItem,
|
||||||
SubscriptionMessageTemplate,
|
SubscriptionMessageTemplate,
|
||||||
SubscriptionMessageTemplateConfig,
|
SubscriptionMessageTemplateConfig,
|
||||||
|
SubscriptionQuotaItem,
|
||||||
|
SubscriptionQuotasResponse,
|
||||||
UserProfileResponse,
|
UserProfileResponse,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { post } from './request'
|
import { get, post } from './request'
|
||||||
|
|
||||||
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
type TemplateResult = SubscriptionMessageRequestItem['result'] | 'tmplIds empty' | 'err' | 'undefined'
|
||||||
|
|
||||||
@@ -163,11 +165,22 @@ function normalizeSubscribeResults(
|
|||||||
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
.filter((item): item is SubscriptionMessageRequestItem => item !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SUBSCRIBE_BUNDLE_BOOKING: SubscriptionMessageScene[] = [
|
||||||
|
SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SUBSCRIBE_BUNDLE_CANCEL: SubscriptionMessageScene[] = [
|
||||||
|
SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在当前调用栈同步调起 `uni.requestSubscribeMessage`。
|
* 在当前调用栈同步调起 `uni.requestSubscribeMessage`(支持多场景组合打包,最多 3 个模板)。
|
||||||
* 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。
|
* 微信要求授权框必须落在 tap / 支付 success 的同步栈里,因此这里不能先 `await`。
|
||||||
*/
|
*/
|
||||||
export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
export function requestSubscriptionBundle(scenes: SubscriptionMessageScene[]): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
if (!isMpWeixin()) {
|
if (!isMpWeixin()) {
|
||||||
return Promise.resolve([])
|
return Promise.resolve([])
|
||||||
}
|
}
|
||||||
@@ -177,15 +190,28 @@ export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Pro
|
|||||||
return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试'))
|
return Promise.reject(new Error('订阅消息模板尚未初始化,请重新进入页面后重试'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const templates = getTemplatesByScene(config, scene)
|
// 按场景收集模板,去重 templateId,微信单次最多支持 3 个模板
|
||||||
|
const templateMap = new Map<string, SubscriptionMessageTemplate>()
|
||||||
|
for (const scene of scenes) {
|
||||||
|
const list = getTemplatesByScene(config, scene)
|
||||||
|
for (const tpl of list) {
|
||||||
|
if (tpl.templateId && !templateMap.has(tpl.templateId)) {
|
||||||
|
templateMap.set(tpl.templateId, tpl)
|
||||||
|
}
|
||||||
|
if (templateMap.size >= 3) break
|
||||||
|
}
|
||||||
|
if (templateMap.size >= 3) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = Array.from(templateMap.values())
|
||||||
if (templates.length === 0) {
|
if (templates.length === 0) {
|
||||||
console.error('[subscribe] no templates matched scene', stringifyDebugPayload({ scene, config, debugContext: getSubscribeDebugContext() }))
|
console.error('[subscribe] no templates matched bundle', stringifyDebugPayload({ scenes, config, debugContext: getSubscribeDebugContext() }))
|
||||||
return Promise.resolve([])
|
return Promise.resolve([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateIds = templates.map((item) => item.templateId)
|
const templateIds = templates.map((item) => item.templateId)
|
||||||
const debugContext = getSubscribeDebugContext()
|
const debugContext = getSubscribeDebugContext()
|
||||||
console.log('[subscribe] requestSubscribeMessage:start', stringifyDebugPayload({ scene, templateIds, templates, debugContext }))
|
console.log('[subscribe] requestSubscriptionBundle:start', stringifyDebugPayload({ scenes, templateIds, templates, debugContext }))
|
||||||
|
|
||||||
return new Promise<SubscriptionMessageRequestItem[]>((resolve, reject) => {
|
return new Promise<SubscriptionMessageRequestItem[]>((resolve, reject) => {
|
||||||
uni.requestSubscribeMessage({
|
uni.requestSubscribeMessage({
|
||||||
@@ -193,22 +219,78 @@ export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Pro
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
const response = res as RequestSubscribeMessageSuccess
|
const response = res as RequestSubscribeMessageSuccess
|
||||||
const requests = normalizeSubscribeResults(templates, response)
|
const requests = normalizeSubscribeResults(templates, response)
|
||||||
console.log('[subscribe] requestSubscribeMessage:success', stringifyDebugPayload({ scene, response, templateIds, debugContext }))
|
console.log('[subscribe] requestSubscriptionBundle:success', stringifyDebugPayload({ scenes, response, templateIds, debugContext }))
|
||||||
console.log('[subscribe] requestSubscribeMessage:normalized', stringifyDebugPayload({ scene, result: response, requests, templateIds, debugContext }))
|
console.log('[subscribe] requestSubscriptionBundle:normalized', stringifyDebugPayload({ scenes, result: response, requests, templateIds, debugContext }))
|
||||||
void reportResults(requests)
|
void reportResults(requests)
|
||||||
.then(() => resolve(requests))
|
.then(() => resolve(requests))
|
||||||
.catch(reject)
|
.catch(reject)
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
console.error('[subscribe] requestSubscribeMessage:fail', stringifyDebugPayload({ scene, error: err, templateIds, debugContext }))
|
console.error('[subscribe] requestSubscriptionBundle:fail', stringifyDebugPayload({ scenes, error: err, templateIds, debugContext }))
|
||||||
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scene, templateIds))
|
reject(buildSubscribeError(err as RequestSubscribeMessageFail, scenes[0], templateIds))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requestSubscriptionMessage(scene: SubscriptionMessageScene): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionBundle([scene])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 约课三合一组合授权:包含约课成功确认、开课前1小时提醒、课程取消通知
|
||||||
|
* 一次点击,三个场景额度同时 +1!
|
||||||
|
*/
|
||||||
|
export function requestBookingBundleSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_BOOKING)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容原有命名,直接升级为约课三合一组合授权
|
||||||
|
*/
|
||||||
export function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
export function requestBookingCreatedSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
return requestSubscriptionMessage(SubscriptionMessageScene.BOOKING_CREATED)
|
return requestBookingBundleSubscriptionMessage()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消预约时的组合授权:课程取消通知 + 下次上课提醒
|
||||||
|
*/
|
||||||
|
export function requestBookingCancelSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionBundle(SUBSCRIBE_BUNDLE_CANCEL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上课前1小时提醒单项授权
|
||||||
|
*/
|
||||||
|
export function requestClassReminderSubscriptionMessage(): Promise<SubscriptionMessageRequestItem[]> {
|
||||||
|
return requestSubscriptionMessage(SubscriptionMessageScene.CLASS_REMINDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户的各订阅场景额度水位
|
||||||
|
*/
|
||||||
|
export async function fetchUserSubscriptionQuotas(): Promise<SubscriptionQuotaItem[]> {
|
||||||
|
try {
|
||||||
|
const res = await get<SubscriptionQuotasResponse>('/user/subscription-messages/quotas')
|
||||||
|
return res.quotas || []
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[subscribe] fetchUserSubscriptionQuotas failed', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 引导打开微信系统设置页,供用户恢复通知授权
|
||||||
|
*/
|
||||||
|
export function openSubscribeSettings(): Promise<boolean> {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
uni.openSetting({
|
||||||
|
success: (res) => {
|
||||||
|
resolve(!!res.authSetting)
|
||||||
|
},
|
||||||
|
fail: () => resolve(false),
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
|
export async function requestAdminBookingSubscriptionCount(): Promise<UserProfileResponse | null> {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ API_BASE_URL=https://focus.richarjiang.com/
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=antYfc85gvwImFZ9kM4UiqMOywJxbqFVgKHLH3NikII
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
|
||||||
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=QJaTOSq_QpyL_spdNRTUfbkmeWDDi5iDAYZyXrFAPc8
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=QJaTOSq_QpyL_spdNRTUfbkmeWDDi5iDAYZyXrFAPc8
|
||||||
|
|
||||||
# COS upload
|
# COS upload
|
||||||
|
|||||||
@@ -14,3 +14,6 @@ COS_UPLOAD_DURATION_SECONDS=1800
|
|||||||
|
|
||||||
# WeChat subscribe message for class review reminders (24h after completion)
|
# WeChat subscribe message for class review reminders (24h after completion)
|
||||||
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW=
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED=
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED=5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM
|
||||||
|
WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER=CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `bookings` ADD COLUMN `class_reminder_claimed_at` DATETIME(3) NULL,
|
||||||
|
ADD COLUMN `class_reminder_due_at` DATETIME(3) NULL,
|
||||||
|
ADD COLUMN `class_reminder_sent_at` DATETIME(3) NULL;
|
||||||
@@ -220,6 +220,9 @@ model Booking {
|
|||||||
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
|
reviewReminderDueAt DateTime? @map("review_reminder_due_at")
|
||||||
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
|
reviewReminderClaimedAt DateTime? @map("review_reminder_claimed_at")
|
||||||
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
|
reviewReminderSentAt DateTime? @map("review_reminder_sent_at")
|
||||||
|
classReminderDueAt DateTime? @map("class_reminder_due_at")
|
||||||
|
classReminderClaimedAt DateTime? @map("class_reminder_claimed_at")
|
||||||
|
classReminderSentAt DateTime? @map("class_reminder_sent_at")
|
||||||
statusHistory BookingStatusHistory[]
|
statusHistory BookingStatusHistory[]
|
||||||
|
|
||||||
@@unique([userId, timeSlotId])
|
@@unique([userId, timeSlotId])
|
||||||
|
|||||||
@@ -170,7 +170,12 @@ describe('BookingService', () => {
|
|||||||
let service: BookingService
|
let service: BookingService
|
||||||
let prisma: jest.Mocked<PrismaService>
|
let prisma: jest.Mocked<PrismaService>
|
||||||
let studioService: jest.Mocked<StudioService>
|
let studioService: jest.Mocked<StudioService>
|
||||||
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
|
let subscriptionMessageService: {
|
||||||
|
sendBookingConfirmedMessage: jest.Mock
|
||||||
|
sendAdminBookingCreatedMessage: jest.Mock
|
||||||
|
sendBookingCancelledMessage: jest.Mock
|
||||||
|
sendClassReminderMessage: jest.Mock
|
||||||
|
}
|
||||||
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
|
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -223,6 +228,8 @@ describe('BookingService', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
sendBookingConfirmedMessage: jest.fn(),
|
sendBookingConfirmedMessage: jest.fn(),
|
||||||
sendAdminBookingCreatedMessage: jest.fn(),
|
sendAdminBookingCreatedMessage: jest.fn(),
|
||||||
|
sendBookingCancelledMessage: jest.fn(),
|
||||||
|
sendClassReminderMessage: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1083,6 +1090,35 @@ describe('BookingService', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('triggers sendBookingCancelledMessage when a booking is cancelled', async () => {
|
||||||
|
const futureDate = new Date(Date.now() + 86400000 * 2)
|
||||||
|
const futureSlot = { ...mockOpenSlot, date: futureDate, startTime: '14:00', endTime: '15:00' }
|
||||||
|
const ownBooking = {
|
||||||
|
...mockConfirmedBooking,
|
||||||
|
userId: MOCK_USER_ID,
|
||||||
|
timeSlot: futureSlot,
|
||||||
|
membership: mockActiveMembership,
|
||||||
|
}
|
||||||
|
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(ownBooking)
|
||||||
|
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: MOCK_USER_ID, openid: 'test-user-openid' } as any)
|
||||||
|
const tx = buildTxMock()
|
||||||
|
tx.booking.update.mockResolvedValue({ ...ownBooking, status: BookingStatus.CANCELLED, cancelledAt: new Date() })
|
||||||
|
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||||
|
tx.membership.update.mockResolvedValue({ ...mockActiveMembership, remainingTimes: 6 })
|
||||||
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
|
|
||||||
|
await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
|
||||||
|
|
||||||
|
expect(subscriptionMessageService.sendBookingCancelledMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
openid: 'test-user-openid',
|
||||||
|
userId: MOCK_USER_ID,
|
||||||
|
bookingId: MOCK_BOOKING_ID,
|
||||||
|
courseName: 'Test Studio',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── getMyBookings ────────────────────────────────────────────────────────
|
// ─── getMyBookings ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ export class BookingService {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
await this.trySendBookingCancelledSubscriptionMessage(booking)
|
||||||
return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded }
|
return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,6 +618,8 @@ export class BookingService {
|
|||||||
return cancelled
|
return cancelled
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await this.trySendBookingCancelledSubscriptionMessage(booking)
|
||||||
|
|
||||||
return { booking: { ...updatedBooking }, refunded }
|
return { booking: { ...updatedBooking }, refunded }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,6 +966,39 @@ export class BookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async trySendBookingCancelledSubscriptionMessage(
|
||||||
|
booking: {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
timeSlot: { date: Date; startTime: string; endTime: string }
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: booking.userId },
|
||||||
|
select: { openid: true },
|
||||||
|
})
|
||||||
|
if (!user?.openid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const studio = await this.studioService.getInfo()
|
||||||
|
const dateLabel = this.formatLocalDate(booking.timeSlot.date)
|
||||||
|
const courseTime = `${booking.timeSlot.startTime.slice(0, 5)}-${booking.timeSlot.endTime.slice(0, 5)}`
|
||||||
|
|
||||||
|
await this.subscriptionMessageService.sendBookingCancelledMessage({
|
||||||
|
openid: user.openid,
|
||||||
|
userId: booking.userId,
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingDate: dateLabel,
|
||||||
|
courseTime,
|
||||||
|
courseName: studio.name || '普拉提课程',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send booking cancelled subscription message failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async trySendAdminBookingCreatedSubscriptionMessages(
|
private async trySendAdminBookingCreatedSubscriptionMessages(
|
||||||
booking: BookingWithRelations,
|
booking: BookingWithRelations,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { ClassReminderService } from '../class-reminder.service'
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service'
|
||||||
|
import { SubscriptionMessageService } from '../../user/subscription-message.service'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
|
import { Logger } from '@nestjs/common'
|
||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
describe('ClassReminderService', () => {
|
||||||
|
const db = {
|
||||||
|
booking: { findMany: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
|
||||||
|
studioConfig: { findFirst: jest.fn() },
|
||||||
|
}
|
||||||
|
const messages = {
|
||||||
|
getClassReminderTemplateId: jest.fn(),
|
||||||
|
sendClassReminderMessage: jest.fn(),
|
||||||
|
}
|
||||||
|
const config = { get: jest.fn() }
|
||||||
|
let service: ClassReminderService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
messages.getClassReminderTemplateId.mockReturnValue('reminder-template-id')
|
||||||
|
db.studioConfig.findFirst.mockResolvedValue({ name: 'FocusCore Studio' })
|
||||||
|
service = new ClassReminderService(
|
||||||
|
db as unknown as PrismaService,
|
||||||
|
messages as unknown as SubscriptionMessageService,
|
||||||
|
config as unknown as ConfigService,
|
||||||
|
)
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {})
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks())
|
||||||
|
|
||||||
|
it('skips run when class reminder template is not configured', async () => {
|
||||||
|
messages.getClassReminderTemplateId.mockReturnValue('')
|
||||||
|
await service.run()
|
||||||
|
expect(db.booking.findMany).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scans and sends reminders for bookings starting in ~1 hour (e.g. 60 mins)', async () => {
|
||||||
|
// Current time fixed or calculated
|
||||||
|
const now = new Date()
|
||||||
|
const targetDate = new Date(now.getTime() + 60 * 60 * 1000)
|
||||||
|
// Convert targetDate to China time string representation
|
||||||
|
// China is UTC+8
|
||||||
|
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
|
||||||
|
const dateStr = chinaDate.toISOString().slice(0, 10)
|
||||||
|
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
|
||||||
|
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
|
||||||
|
const startTime = `${hours}:${minutes}:00`
|
||||||
|
|
||||||
|
db.booking.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'booking-reminder-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
user: { openid: 'openid-user-1' },
|
||||||
|
timeSlot: {
|
||||||
|
date: new Date(`${dateStr}T00:00:00.000Z`),
|
||||||
|
startTime,
|
||||||
|
endTime: '12:00:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
db.booking.updateMany.mockResolvedValue({ count: 1 })
|
||||||
|
messages.sendClassReminderMessage.mockResolvedValue(true)
|
||||||
|
|
||||||
|
await service.run()
|
||||||
|
|
||||||
|
expect(db.booking.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: 'booking-reminder-1',
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
classReminderClaimedAt: null,
|
||||||
|
},
|
||||||
|
data: { classReminderClaimedAt: expect.any(Date) },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(messages.sendClassReminderMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
openid: 'openid-user-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
bookingId: 'booking-reminder-1',
|
||||||
|
courseName: 'FocusCore Studio',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(db.booking.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'booking-reminder-1' },
|
||||||
|
data: { classReminderSentAt: expect.any(Date) },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips bookings that are not in the 50-70 minutes window (e.g. starting in 2 hours)', async () => {
|
||||||
|
const now = new Date()
|
||||||
|
const targetDate = new Date(now.getTime() + 120 * 60 * 1000)
|
||||||
|
const chinaDate = new Date(targetDate.getTime() + 8 * 3600 * 1000)
|
||||||
|
const dateStr = chinaDate.toISOString().slice(0, 10)
|
||||||
|
const hours = String(chinaDate.getUTCHours()).padStart(2, '0')
|
||||||
|
const minutes = String(chinaDate.getUTCMinutes()).padStart(2, '0')
|
||||||
|
|
||||||
|
db.booking.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'booking-far',
|
||||||
|
userId: 'user-far',
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
user: { openid: 'openid-far' },
|
||||||
|
timeSlot: {
|
||||||
|
date: new Date(`${dateStr}T00:00:00.000Z`),
|
||||||
|
startTime: `${hours}:${minutes}:00`,
|
||||||
|
endTime: '20:00:00',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await service.run()
|
||||||
|
|
||||||
|
expect(db.booking.updateMany).not.toHaveBeenCalled()
|
||||||
|
expect(messages.sendClassReminderMessage).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
107
packages/server/src/scheduler/class-reminder.service.ts
Normal file
107
packages/server/src/scheduler/class-reminder.service.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common'
|
||||||
|
import { Cron } from '@nestjs/schedule'
|
||||||
|
import { ConfigService } from '@nestjs/config'
|
||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
import { SubscriptionMessageService } from '../user/subscription-message.service'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClassReminderService {
|
||||||
|
private readonly logger = new Logger(ClassReminderService.name)
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly messages: SubscriptionMessageService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run every 5 minutes to scan for upcoming confirmed classes starting in ~1 hour (50-70 mins).
|
||||||
|
*/
|
||||||
|
@Cron('*/5 * * * *')
|
||||||
|
async run(): Promise<void> {
|
||||||
|
const templateId = this.messages.getClassReminderTemplateId()
|
||||||
|
if (!templateId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const nowMs = now.getTime()
|
||||||
|
|
||||||
|
// Restrict date range to around today to avoid full table scan
|
||||||
|
const yesterday = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1))
|
||||||
|
const inTwoDays = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 2))
|
||||||
|
|
||||||
|
const studio = await this.prisma.studioConfig.findFirst({ select: { name: true } })
|
||||||
|
const courseName = studio?.name || '普拉提课程'
|
||||||
|
|
||||||
|
const rows = await this.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
classReminderClaimedAt: null,
|
||||||
|
classReminderSentAt: null,
|
||||||
|
timeSlot: {
|
||||||
|
date: {
|
||||||
|
gte: yesterday,
|
||||||
|
lte: inTwoDays,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { openid: true } },
|
||||||
|
timeSlot: { select: { date: true, startTime: true, endTime: true } },
|
||||||
|
},
|
||||||
|
take: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const dateStr = row.timeSlot.date.toISOString().slice(0, 10)
|
||||||
|
const startTime = row.timeSlot.startTime.slice(0, 5)
|
||||||
|
const classStartMs = new Date(`${dateStr}T${startTime}:00+08:00`).getTime()
|
||||||
|
const diffMs = classStartMs - nowMs
|
||||||
|
|
||||||
|
// Target: starting in 50 to 70 minutes (around 1 hour ahead)
|
||||||
|
if (diffMs < 50 * 60 * 1000 || diffMs > 70 * 60 * 1000) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically claim the booking to prevent race conditions
|
||||||
|
const claimed = await this.prisma.booking.updateMany({
|
||||||
|
where: {
|
||||||
|
id: row.id,
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
classReminderClaimedAt: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
classReminderClaimedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!claimed.count) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const classTime = `${dateStr} ${startTime}`
|
||||||
|
const sent = await this.messages.sendClassReminderMessage({
|
||||||
|
openid: row.user.openid,
|
||||||
|
userId: row.userId,
|
||||||
|
bookingId: row.id,
|
||||||
|
courseName,
|
||||||
|
classTime,
|
||||||
|
tips: '课程将于1小时后开始,请准时出席',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
await this.prisma.booking.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { classReminderSentAt: new Date() },
|
||||||
|
})
|
||||||
|
this.logger.log(`Class reminder sent successfully for booking ${row.id}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to process class reminder for booking ${row.id}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { UserModule } from '../user/user.module'
|
import { UserModule } from '../user/user.module'
|
||||||
import { ConfigModule } from '@nestjs/config'
|
import { ConfigModule } from '@nestjs/config'
|
||||||
import { ReviewReminderService } from './review-reminder.service'
|
import { ReviewReminderService } from './review-reminder.service'
|
||||||
|
import { ClassReminderService } from './class-reminder.service'
|
||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { ScheduleModule } from '@nestjs/schedule'
|
import { ScheduleModule } from '@nestjs/schedule'
|
||||||
import { TimeSlotModule } from '../time-slot/time-slot.module'
|
import { TimeSlotModule } from '../time-slot/time-slot.module'
|
||||||
@@ -12,6 +13,6 @@ import { SchedulerService } from './scheduler.service'
|
|||||||
UserModule, ConfigModule,
|
UserModule, ConfigModule,
|
||||||
TimeSlotModule,
|
TimeSlotModule,
|
||||||
],
|
],
|
||||||
providers: [SchedulerService, ReviewReminderService],
|
providers: [SchedulerService, ReviewReminderService, ClassReminderService],
|
||||||
})
|
})
|
||||||
export class SchedulerModule {}
|
export class SchedulerModule {}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { buildClassReviewSubscribeData } from '../subscription-message.service'
|
import {
|
||||||
|
buildClassReviewSubscribeData,
|
||||||
|
buildBookingCancelledSubscribeData,
|
||||||
|
buildClassReminderSubscribeData,
|
||||||
|
} from '../subscription-message.service'
|
||||||
|
|
||||||
describe('Class review subscribe payload', () => {
|
describe('Class review subscribe payload', () => {
|
||||||
it('fills thing1 course, thing2 coach, time3 class time and thing4 tip', () => {
|
it('fills thing1 course, thing2 coach, time3 class time and thing4 tip', () => {
|
||||||
@@ -25,3 +29,51 @@ describe('Class review subscribe payload', () => {
|
|||||||
expect(data.time3.value).toBe('2026年01月02日 09:30')
|
expect(data.time3.value).toBe('2026年01月02日 09:30')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Booking cancelled subscribe payload', () => {
|
||||||
|
it('formats date11, date1 and thing2 correctly', () => {
|
||||||
|
const data = buildBookingCancelledSubscribeData({
|
||||||
|
bookingDate: '2026-09-10',
|
||||||
|
courseTime: '10:00-11:00',
|
||||||
|
courseName: '普拉提一对一私教体验课',
|
||||||
|
})
|
||||||
|
expect(data).toEqual({
|
||||||
|
date11: { value: '2026-09-10' },
|
||||||
|
date1: { value: '10:00-11:00' },
|
||||||
|
thing2: { value: '普拉提一对一私教体验课' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims courseName to 20 characters', () => {
|
||||||
|
const data = buildBookingCancelledSubscribeData({
|
||||||
|
bookingDate: '2026-09-10',
|
||||||
|
courseTime: '10:00-11:00',
|
||||||
|
courseName: '这是一个超过二十个汉字的普拉提非常长非常长非常长的课程名称',
|
||||||
|
})
|
||||||
|
expect(data.thing2.value).toHaveLength(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Class reminder subscribe payload', () => {
|
||||||
|
it('formats thing1, time2 and thing5 correctly', () => {
|
||||||
|
const data = buildClassReminderSubscribeData({
|
||||||
|
courseName: '普拉提核心床小班课',
|
||||||
|
classTime: '2026-09-10 15:00',
|
||||||
|
tips: '课程将于1小时后开始,请准时出席',
|
||||||
|
})
|
||||||
|
expect(data).toEqual({
|
||||||
|
thing1: { value: '普拉提核心床小班课' },
|
||||||
|
time2: { value: '2026-09-10 15:00' },
|
||||||
|
thing5: { value: '课程将于1小时后开始,请准时出席' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to default tips and trims fields to 20 characters', () => {
|
||||||
|
const data = buildClassReminderSubscribeData({
|
||||||
|
courseName: '超过二十个字的超长课程名称请务必进行截断处理测试',
|
||||||
|
classTime: '14:00',
|
||||||
|
})
|
||||||
|
expect(data.thing1.value).toHaveLength(20)
|
||||||
|
expect(data.thing5.value).toBe('课程即将于1小时后开始,请准时出席'.slice(0, 20))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ const mockPrisma = {
|
|||||||
const mockConfigService = {
|
const mockConfigService = {
|
||||||
get: jest.fn((key: string, defaultValue = '') => {
|
get: jest.fn((key: string, defaultValue = '') => {
|
||||||
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed'
|
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED') return 'tmpl-booking-confirmed'
|
||||||
|
if (key === 'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED') return '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM'
|
||||||
|
if (key === 'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER') return 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0'
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,19 @@ describe('UserService', () => {
|
|||||||
{
|
{
|
||||||
templateId: 'tmpl-booking-confirmed',
|
templateId: 'tmpl-booking-confirmed',
|
||||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
description: '预约成功后推送确认通知',
|
||||||
|
usageTarget: 'consent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateId: '5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
description: '约课取消后推送取消通知',
|
||||||
|
usageTarget: 'consent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateId: 'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
|
||||||
|
scene: SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
description: '开课前 1 小时推送上课提醒',
|
||||||
usageTarget: 'consent',
|
usageTarget: 'consent',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -282,7 +296,7 @@ describe('UserService', () => {
|
|||||||
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
expect(result.avatarUrl).toBe('https://example.com/new.png')
|
||||||
expect(result.activeMembershipCount).toBe(1)
|
expect(result.activeMembershipCount).toBe(1)
|
||||||
expect(result.adminBookingSubscriptionCount).toBe(0)
|
expect(result.adminBookingSubscriptionCount).toBe(0)
|
||||||
expect(result.subscriptionMessageTemplates.templates).toHaveLength(2)
|
expect(result.subscriptionMessageTemplates.templates).toHaveLength(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('increments admin booking subscription count for admin users', async () => {
|
it('increments admin booking subscription count for admin users', async () => {
|
||||||
@@ -742,4 +756,31 @@ describe('UserService', () => {
|
|||||||
expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled()
|
expect(mockPrisma.membership.findFirst).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getUserSubscriptionQuotas', () => {
|
||||||
|
it('returns calculated quotas for consent templates', async () => {
|
||||||
|
mockPrisma.subscriptionMessageConsent.findMany.mockResolvedValue([
|
||||||
|
{
|
||||||
|
templateId: 'tmpl-booking-confirmed',
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
|
acceptCount: 5,
|
||||||
|
sentCount: 2,
|
||||||
|
lastResult: 'accept',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const res = await service.getUserSubscriptionQuotas('user-1')
|
||||||
|
expect(res.quotas).toHaveLength(3) // BOOKING_CREATED, BOOKING_CANCELLED, CLASS_REMINDER
|
||||||
|
const bookingQuota = res.quotas.find((q) => q.scene === SubscriptionMessageScene.BOOKING_CREATED)
|
||||||
|
expect(bookingQuota).toBeDefined()
|
||||||
|
expect(bookingQuota?.remainingQuota).toBe(3)
|
||||||
|
expect(bookingQuota?.acceptCount).toBe(5)
|
||||||
|
expect(bookingQuota?.sentCount).toBe(2)
|
||||||
|
expect(bookingQuota?.lastResult).toBe('accept')
|
||||||
|
|
||||||
|
const cancelledQuota = res.quotas.find((q) => q.scene === SubscriptionMessageScene.BOOKING_CANCELLED)
|
||||||
|
expect(cancelledQuota).toBeDefined()
|
||||||
|
expect(cancelledQuota?.remainingQuota).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ interface BookingConfirmedTemplatePayload {
|
|||||||
readonly bookingEndTime: string
|
readonly bookingEndTime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BookingCancelledTemplatePayload {
|
||||||
|
readonly openid: string
|
||||||
|
readonly userId?: string
|
||||||
|
readonly bookingId: string
|
||||||
|
readonly bookingDate: string
|
||||||
|
readonly courseTime: string
|
||||||
|
readonly courseName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassReminderTemplatePayload {
|
||||||
|
readonly openid: string
|
||||||
|
readonly userId?: string
|
||||||
|
readonly bookingId: string
|
||||||
|
readonly courseName: string
|
||||||
|
readonly classTime: string
|
||||||
|
readonly tips?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface AdminBookingCreatedTemplatePayload {
|
interface AdminBookingCreatedTemplatePayload {
|
||||||
readonly openid: string
|
readonly openid: string
|
||||||
readonly bookingId: string
|
readonly bookingId: string
|
||||||
@@ -56,6 +74,22 @@ export function buildClassReviewSubscribeData(input: { studioName: string | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildBookingCancelledSubscribeData(input: { bookingDate: string; courseTime: string; courseName: string }) {
|
||||||
|
return {
|
||||||
|
date11: { value: input.bookingDate },
|
||||||
|
date1: { value: input.courseTime },
|
||||||
|
thing2: { value: (input.courseName || '普拉提私教').slice(0, 20) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildClassReminderSubscribeData(input: { courseName: string; classTime: string; tips?: string }) {
|
||||||
|
return {
|
||||||
|
thing1: { value: (input.courseName || '普拉提私教').slice(0, 20) },
|
||||||
|
time2: { value: input.classTime },
|
||||||
|
thing5: { value: (input.tips || '课程即将于1小时后开始,请准时出席').slice(0, 20) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionMessageService {
|
export class SubscriptionMessageService {
|
||||||
private readonly logger = new Logger(SubscriptionMessageService.name)
|
private readonly logger = new Logger(SubscriptionMessageService.name)
|
||||||
@@ -70,10 +104,154 @@ export class SubscriptionMessageService {
|
|||||||
return this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', '')
|
return this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBookingCancelledTemplateId(): string {
|
||||||
|
return this.configService.get<string>(
|
||||||
|
'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
|
||||||
|
'5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getClassReminderTemplateId(): string {
|
||||||
|
return this.configService.get<string>(
|
||||||
|
'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER',
|
||||||
|
'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
async sendBookingConfirmedMessage(payload: BookingConfirmedTemplatePayload): Promise<boolean> {
|
||||||
return this.sendConsentBasedBookingMessage(payload)
|
return this.sendConsentBasedBookingMessage(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendBookingCancelledMessage(payload: BookingCancelledTemplatePayload): Promise<boolean> {
|
||||||
|
const templateId = this.getBookingCancelledTemplateId()
|
||||||
|
if (!templateId) {
|
||||||
|
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED is not configured, skip sending cancelled message')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const consent = await this.prisma.subscriptionMessageConsent.findFirst({
|
||||||
|
where: {
|
||||||
|
user: payload.userId ? { id: payload.userId } : { openid: payload.openid },
|
||||||
|
templateId,
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
acceptCount: { gt: 0 },
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ lastRequestedAt: 'desc' },
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!consent) {
|
||||||
|
this.logger.warn(`No subscription quota found for booking cancelled: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consent.sentCount >= consent.acceptCount) {
|
||||||
|
this.logger.warn(`Subscription quota exhausted for booking cancelled: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({
|
||||||
|
where: {
|
||||||
|
id: consent.id,
|
||||||
|
sentCount: consent.sentCount,
|
||||||
|
acceptCount: { gt: consent.sentCount },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sentCount: { increment: 1 },
|
||||||
|
lastSentAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!claimed.count) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = buildBookingCancelledSubscribeData({
|
||||||
|
bookingDate: payload.bookingDate,
|
||||||
|
courseTime: payload.courseTime,
|
||||||
|
courseName: payload.courseName,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.postWechatSubscribeSend({
|
||||||
|
openid: payload.openid,
|
||||||
|
templateId,
|
||||||
|
page: `pages/booking/detail?id=${payload.bookingId}`,
|
||||||
|
data,
|
||||||
|
logContext: {
|
||||||
|
target: 'member',
|
||||||
|
bookingId: payload.bookingId,
|
||||||
|
consentId: consent.id,
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendClassReminderMessage(payload: ClassReminderTemplatePayload): Promise<boolean> {
|
||||||
|
const templateId = this.getClassReminderTemplateId()
|
||||||
|
if (!templateId) {
|
||||||
|
this.logger.warn('WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER is not configured, skip sending reminder message')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const consent = await this.prisma.subscriptionMessageConsent.findFirst({
|
||||||
|
where: {
|
||||||
|
user: payload.userId ? { id: payload.userId } : { openid: payload.openid },
|
||||||
|
templateId,
|
||||||
|
scene: SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
acceptCount: { gt: 0 },
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ lastRequestedAt: 'desc' },
|
||||||
|
{ updatedAt: 'desc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!consent) {
|
||||||
|
this.logger.warn(`No subscription quota found for class reminder: ${stringifyDebugPayload({ openid: payload.openid, bookingId: payload.bookingId, templateId })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (consent.sentCount >= consent.acceptCount) {
|
||||||
|
this.logger.warn(`Subscription quota exhausted for class reminder: ${stringifyDebugPayload({ consentId: consent.id, bookingId: payload.bookingId, sentCount: consent.sentCount, acceptCount: consent.acceptCount, templateId })}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({
|
||||||
|
where: {
|
||||||
|
id: consent.id,
|
||||||
|
sentCount: consent.sentCount,
|
||||||
|
acceptCount: { gt: consent.sentCount },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sentCount: { increment: 1 },
|
||||||
|
lastSentAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!claimed.count) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = buildClassReminderSubscribeData({
|
||||||
|
courseName: payload.courseName,
|
||||||
|
classTime: payload.classTime,
|
||||||
|
tips: payload.tips,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.postWechatSubscribeSend({
|
||||||
|
openid: payload.openid,
|
||||||
|
templateId,
|
||||||
|
page: `pages/booking/detail?id=${payload.bookingId}`,
|
||||||
|
data,
|
||||||
|
logContext: {
|
||||||
|
target: 'member',
|
||||||
|
bookingId: payload.bookingId,
|
||||||
|
consentId: consent.id,
|
||||||
|
scene: SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async sendAdminBookingCreatedMessage(payload: AdminBookingCreatedTemplatePayload): Promise<boolean> {
|
async sendAdminBookingCreatedMessage(payload: AdminBookingCreatedTemplatePayload): Promise<boolean> {
|
||||||
const templateId = this.getBookingConfirmedTemplateId()
|
const templateId = this.getBookingConfirmedTemplateId()
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
@@ -182,28 +360,22 @@ export class SubscriptionMessageService {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendWechatSubscribeMessage(params: {
|
private async postWechatSubscribeSend(params: {
|
||||||
openid: string
|
openid: string
|
||||||
bookingId: string
|
|
||||||
templateId: string
|
templateId: string
|
||||||
payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
|
page: string
|
||||||
|
data: Record<string, { value: string }>
|
||||||
logContext: Record<string, unknown>
|
logContext: Record<string, unknown>
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const accessToken = await this.getAccessToken()
|
const accessToken = await this.getAccessToken()
|
||||||
const page = `/pages/booking/detail?id=${params.bookingId}`
|
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
touser: params.openid,
|
touser: params.openid,
|
||||||
template_id: params.templateId,
|
template_id: params.templateId,
|
||||||
page,
|
page: params.page,
|
||||||
data: {
|
data: params.data,
|
||||||
thing1: { value: params.payload.bookingContent.slice(0, 20) },
|
|
||||||
time2: { value: params.payload.bookingTime.slice(0, 20) },
|
|
||||||
thing25: { value: params.payload.courseName.slice(0, 20) },
|
|
||||||
time35: { value: params.payload.bookingEndTime.slice(0, 20) },
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
|
this.logger.log(`WeChat subscribe send request: ${stringifyDebugPayload({ templateId: params.templateId, requestBody, ...params.logContext })}`)
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
|
`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${accessToken}`,
|
||||||
@@ -213,25 +385,49 @@ export class SubscriptionMessageService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const responseText = await response.text()
|
const responseText = await response.text()
|
||||||
this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, bookingId: params.bookingId, templateId: params.templateId, requestBody, ...params.logContext })}`)
|
this.logger.error(`WeChat subscribe send http error: ${stringifyDebugPayload({ status: response.status, statusText: response.statusText, body: responseText, templateId: params.templateId, requestBody, ...params.logContext })}`)
|
||||||
throw new InternalServerErrorException('调用微信订阅消息接口失败')
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = (await response.json()) as WechatSubscribeSendResponse
|
const result = (await response.json()) as WechatSubscribeSendResponse
|
||||||
if (result.errcode && result.errcode !== 0) {
|
if (result.errcode && result.errcode !== 0) {
|
||||||
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
|
this.logger.warn(`WeChat subscribe send failed: ${stringifyDebugPayload({ templateId: params.templateId, requestBody, response: result, ...params.logContext })}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ bookingId: params.bookingId, templateId: params.templateId, response: result, ...params.logContext })}`)
|
this.logger.log(`WeChat subscribe send success: ${stringifyDebugPayload({ templateId: params.templateId, response: result, ...params.logContext })}`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sendWechatSubscribeMessage(params: {
|
||||||
|
openid: string
|
||||||
|
bookingId: string
|
||||||
|
templateId: string
|
||||||
|
payload: BookingConfirmedTemplatePayload | AdminBookingCreatedTemplatePayload
|
||||||
|
logContext: Record<string, unknown>
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const data = {
|
||||||
|
thing1: { value: params.payload.bookingContent.slice(0, 20) },
|
||||||
|
time2: { value: params.payload.bookingTime.slice(0, 20) },
|
||||||
|
thing25: { value: params.payload.courseName.slice(0, 20) },
|
||||||
|
time35: { value: params.payload.bookingEndTime.slice(0, 20) },
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.postWechatSubscribeSend({
|
||||||
|
openid: params.openid,
|
||||||
|
templateId: params.templateId,
|
||||||
|
page: `/pages/booking/detail?id=${params.bookingId}`,
|
||||||
|
data,
|
||||||
|
logContext: { bookingId: params.bookingId, ...params.logContext },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async sendReviewReminder(userId: string, openid: string, bookingId: string): Promise<boolean> {
|
async sendReviewReminder(userId: string, openid: string, bookingId: string): Promise<boolean> {
|
||||||
const templateId = this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', '')
|
const templateId = this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', '')
|
||||||
if (!templateId) return false
|
if (!templateId) return false
|
||||||
@@ -247,15 +443,20 @@ export class SubscriptionMessageService {
|
|||||||
// Reserve quota before external I/O. Ambiguous network outcomes must not cause duplicate sends.
|
// Reserve quota before external I/O. Ambiguous network outcomes must not cause duplicate sends.
|
||||||
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({ where: { id: consent.id, sentCount: consent.sentCount, acceptCount: { gt: consent.sentCount } }, data: { sentCount: { increment: 1 }, lastSentAt: new Date() } })
|
const claimed = await this.prisma.subscriptionMessageConsent.updateMany({ where: { id: consent.id, sentCount: consent.sentCount, acceptCount: { gt: consent.sentCount } }, data: { sentCount: { increment: 1 }, lastSentAt: new Date() } })
|
||||||
if (!claimed.count) return false
|
if (!claimed.count) return false
|
||||||
const token = await this.getAccessToken()
|
|
||||||
const response = await fetch(`https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${token}`, {
|
return this.postWechatSubscribeSend({
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
openid,
|
||||||
body: JSON.stringify({ touser: openid, template_id: templateId, page: `pages/booking/detail?id=${bookingId}`, data }),
|
templateId,
|
||||||
signal: AbortSignal.timeout(15000),
|
page: `pages/booking/detail?id=${bookingId}`,
|
||||||
|
data,
|
||||||
|
logContext: {
|
||||||
|
target: 'member',
|
||||||
|
userId,
|
||||||
|
bookingId,
|
||||||
|
consentId: consent.id,
|
||||||
|
scene: SubscriptionMessageScene.CLASS_REVIEW,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error('评价提醒发送结果未知')
|
|
||||||
const result = await response.json() as WechatSubscribeSendResponse
|
|
||||||
return !result.errcode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAccessToken(): Promise<string> {
|
private async getAccessToken(): Promise<string> {
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export class UserController {
|
|||||||
return this.userService.getSubscriptionMessageTemplates()
|
return this.userService.getSubscriptionMessageTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('user/subscription-messages/quotas')
|
||||||
|
getUserSubscriptionQuotas(@CurrentUser('sub') userId: string) {
|
||||||
|
return this.userService.getUserSubscriptionQuotas(userId)
|
||||||
|
}
|
||||||
|
|
||||||
@Post('user/subscription-messages/report')
|
@Post('user/subscription-messages/report')
|
||||||
reportSubscriptionMessageRequests(
|
reportSubscriptionMessageRequests(
|
||||||
@CurrentUser('sub') userId: string,
|
@CurrentUser('sub') userId: string,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
SubscriptionMessageRequestResult,
|
SubscriptionMessageRequestResult,
|
||||||
SubscriptionMessageTemplate,
|
SubscriptionMessageTemplate,
|
||||||
SubscriptionMessageTemplateConfig,
|
SubscriptionMessageTemplateConfig,
|
||||||
|
SubscriptionQuotaItem,
|
||||||
|
SubscriptionQuotasResponse,
|
||||||
AdminMemberSummary,
|
AdminMemberSummary,
|
||||||
AdminMemberDetail,
|
AdminMemberDetail,
|
||||||
MembershipWithCardType,
|
MembershipWithCardType,
|
||||||
@@ -100,7 +102,25 @@ export class UserService {
|
|||||||
{
|
{
|
||||||
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
|
||||||
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
scene: SubscriptionMessageScene.BOOKING_CREATED,
|
||||||
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
|
description: '预约成功后推送确认通知',
|
||||||
|
usageTarget: 'consent' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateId: this.configService.get<string>(
|
||||||
|
'WX_SUBSCRIBE_TEMPLATE_BOOKING_CANCELLED',
|
||||||
|
'5YX4ml6XwC0NegITxqsUXBPJzwKeOiM4VRZCaTiCgFM',
|
||||||
|
),
|
||||||
|
scene: SubscriptionMessageScene.BOOKING_CANCELLED,
|
||||||
|
description: '约课取消后推送取消通知',
|
||||||
|
usageTarget: 'consent' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateId: this.configService.get<string>(
|
||||||
|
'WX_SUBSCRIBE_TEMPLATE_CLASS_REMINDER',
|
||||||
|
'CHwcRtAu-Bo6kYt-cmHMhBI4gDZdfoTarYqZotWQEi0',
|
||||||
|
),
|
||||||
|
scene: SubscriptionMessageScene.CLASS_REMINDER,
|
||||||
|
description: '开课前 1 小时推送上课提醒',
|
||||||
usageTarget: 'consent' as const,
|
usageTarget: 'consent' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -260,6 +280,48 @@ export class UserService {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserSubscriptionQuotas(userId: string): Promise<SubscriptionQuotasResponse> {
|
||||||
|
const config = this.buildSubscriptionTemplateConfig()
|
||||||
|
const consentTemplates = config.templates.filter((item) => item.usageTarget !== 'counter')
|
||||||
|
|
||||||
|
if (consentTemplates.length === 0) {
|
||||||
|
return { quotas: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const consents = await this.prisma.subscriptionMessageConsent.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
OR: consentTemplates.map((t) => ({
|
||||||
|
templateId: t.templateId,
|
||||||
|
scene: t.scene,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const consentMap = new Map<string, SubscriptionMessageConsentRecord>()
|
||||||
|
for (const consent of consents) {
|
||||||
|
consentMap.set(`${consent.templateId}_${consent.scene}`, consent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotas: SubscriptionQuotaItem[] = consentTemplates.map((tpl) => {
|
||||||
|
const record = consentMap.get(`${tpl.templateId}_${tpl.scene}`)
|
||||||
|
const acceptCount = record?.acceptCount ?? 0
|
||||||
|
const sentCount = record?.sentCount ?? 0
|
||||||
|
const remainingQuota = Math.max(0, acceptCount - sentCount)
|
||||||
|
return {
|
||||||
|
scene: tpl.scene,
|
||||||
|
templateId: tpl.templateId,
|
||||||
|
description: tpl.description,
|
||||||
|
remainingQuota,
|
||||||
|
acceptCount,
|
||||||
|
sentCount,
|
||||||
|
lastResult: (record?.lastResult as SubscriptionMessageRequestResult) ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { quotas }
|
||||||
|
}
|
||||||
|
|
||||||
async grantAdminBookingSubscriptionCount(userId: string): Promise<UserProfileResponse> {
|
async grantAdminBookingSubscriptionCount(userId: string): Promise<UserProfileResponse> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
|
|||||||
@@ -58,5 +58,7 @@ export enum SubscriptionMessageScene {
|
|||||||
CLASS_REVIEW = 'CLASS_REVIEW',
|
CLASS_REVIEW = 'CLASS_REVIEW',
|
||||||
ORDER_PAID = 'ORDER_PAID',
|
ORDER_PAID = 'ORDER_PAID',
|
||||||
BOOKING_CREATED = 'BOOKING_CREATED',
|
BOOKING_CREATED = 'BOOKING_CREATED',
|
||||||
|
BOOKING_CANCELLED = 'BOOKING_CANCELLED',
|
||||||
|
CLASS_REMINDER = 'CLASS_REMINDER',
|
||||||
ADMIN_BOOKING_CREATED = 'ADMIN_BOOKING_CREATED',
|
ADMIN_BOOKING_CREATED = 'ADMIN_BOOKING_CREATED',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export type {
|
|||||||
SubscriptionMessageTemplateConfig,
|
SubscriptionMessageTemplateConfig,
|
||||||
ReportSubscriptionMessageRequestDto,
|
ReportSubscriptionMessageRequestDto,
|
||||||
SubscriptionMessageConsentSummary,
|
SubscriptionMessageConsentSummary,
|
||||||
|
SubscriptionQuotaItem,
|
||||||
|
SubscriptionQuotasResponse,
|
||||||
} from './types/index'
|
} from './types/index'
|
||||||
|
|
||||||
export * from './types/member-care'
|
export * from './types/member-care'
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export type {
|
|||||||
SubscriptionMessageTemplateConfig,
|
SubscriptionMessageTemplateConfig,
|
||||||
ReportSubscriptionMessageRequestDto,
|
ReportSubscriptionMessageRequestDto,
|
||||||
SubscriptionMessageConsentSummary,
|
SubscriptionMessageConsentSummary,
|
||||||
|
SubscriptionQuotaItem,
|
||||||
|
SubscriptionQuotasResponse,
|
||||||
} from './subscription'
|
} from './subscription'
|
||||||
export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type'
|
export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type'
|
||||||
export type { Membership, MembershipWithCardType } from './membership'
|
export type { Membership, MembershipWithCardType } from './membership'
|
||||||
|
|||||||
@@ -40,3 +40,17 @@ export interface SubscriptionMessageConsentSummary {
|
|||||||
readonly createdAt: string
|
readonly createdAt: string
|
||||||
readonly updatedAt: string
|
readonly updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionQuotaItem {
|
||||||
|
readonly scene: SubscriptionMessageScene
|
||||||
|
readonly templateId: string
|
||||||
|
readonly description: string
|
||||||
|
readonly remainingQuota: number
|
||||||
|
readonly acceptCount: number
|
||||||
|
readonly sentCount: number
|
||||||
|
readonly lastResult: SubscriptionMessageRequestResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionQuotasResponse {
|
||||||
|
readonly quotas: SubscriptionQuotaItem[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user