feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# @lehuo/contracts
API DTO、枚举和跨端共享类型的单一事实来源。业务枚举与接口契约必须在此维护,禁止前后端分别复制。
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@lehuo/contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test:unit": "vitest run",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
+49
View File
@@ -0,0 +1,49 @@
export const API_ERROR_CODES = {
AUTH_REQUIRED: 'AUTH_REQUIRED',
AUTH_SESSION_EXPIRED: 'AUTH_SESSION_EXPIRED',
FORBIDDEN: 'FORBIDDEN',
VALIDATION_ERROR: 'VALIDATION_ERROR',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
ACCOUNT_LOCKED: 'ACCOUNT_LOCKED',
ACCOUNT_DISABLED: 'ACCOUNT_DISABLED',
WECHAT_LOGIN_FAILED: 'WECHAT_LOGIN_FAILED',
RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
APPLICATION_NOT_FOUND: 'APPLICATION_NOT_FOUND',
BENEFICIARY_NOT_FOUND: 'BENEFICIARY_NOT_FOUND',
CASE_NOT_FOUND: 'CASE_NOT_FOUND',
SERVICE_RECORD_NOT_FOUND: 'SERVICE_RECORD_NOT_FOUND',
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
FOLLOW_UP_NOT_FOUND: 'FOLLOW_UP_NOT_FOUND',
VOLUNTEER_NOT_FOUND: 'VOLUNTEER_NOT_FOUND',
VOLUNTEER_NOT_APPROVED: 'VOLUNTEER_NOT_APPROVED',
PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND',
ACTIVITY_NOT_FOUND: 'ACTIVITY_NOT_FOUND',
REGISTRATION_NOT_FOUND: 'REGISTRATION_NOT_FOUND',
ACTIVITY_FULL: 'ACTIVITY_FULL',
REGISTRATION_CLOSED: 'REGISTRATION_CLOSED',
RESOURCE_CONFLICT: 'RESOURCE_CONFLICT',
UPLOAD_NOT_READY: 'UPLOAD_NOT_READY',
STORAGE_UNAVAILABLE: 'STORAGE_UNAVAILABLE',
DUPLICATE_REQUEST: 'DUPLICATE_REQUEST',
INVALID_STATE_TRANSITION: 'INVALID_STATE_TRANSITION',
RATE_LIMITED: 'RATE_LIMITED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[keyof typeof API_ERROR_CODES];
export interface ApiSuccessResponse<T> {
success: true;
data: T;
requestId: string;
}
export interface ApiErrorResponse {
success: false;
error: {
code: ApiErrorCode;
message: string;
details?: Record<string, string[]>;
};
requestId: string;
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { API_ERROR_CODES, PERMISSION_CODES, ROLE_CODES } from './index.js';
describe('authentication contracts', () => {
it('keeps approved role and permission codes centralized', () => {
expect(ROLE_CODES).toContain('VOLUNTEER');
expect(ROLE_CODES).toContain('SUPER_ADMIN');
expect(PERMISSION_CODES.ADMIN_ACCESS).toBe('admin:access');
});
it('uses stable public error codes', () => {
expect(API_ERROR_CODES.AUTH_REQUIRED).toBe('AUTH_REQUIRED');
expect(API_ERROR_CODES.INVALID_CREDENTIALS).toBe('INVALID_CREDENTIALS');
});
});
+102
View File
@@ -0,0 +1,102 @@
export const USER_STATUSES = ['ACTIVE', 'SUSPENDED', 'DISABLED'] as const;
export type UserStatus = (typeof USER_STATUSES)[number];
export const USER_TYPES = ['PUBLIC', 'STAFF'] as const;
export type UserType = (typeof USER_TYPES)[number];
export const ROLE_CODES = [
'VOLUNTEER',
'STAFF',
'PROJECT_MANAGER',
'ADMIN',
'SUPER_ADMIN',
] as const;
export type RoleCode = (typeof ROLE_CODES)[number];
export const PERMISSION_CODES = {
ADMIN_ACCESS: 'admin:access',
USERS_READ: 'users:read',
USERS_MANAGE: 'users:manage',
ROLES_MANAGE: 'roles:manage',
AUDIT_READ: 'audit:read',
SYSTEM_MANAGE: 'system:manage',
SENSITIVE_READ: 'sensitive:read',
SENSITIVE_EXPORT: 'sensitive:export',
RESOURCES_ACCESS_ALL: 'resources:access_all',
APPLICATIONS_READ: 'applications:read',
APPLICATIONS_MANAGE: 'applications:manage',
BENEFICIARIES_READ: 'beneficiaries:read',
BENEFICIARIES_MANAGE: 'beneficiaries:manage',
CASES_READ: 'cases:read',
CASES_MANAGE: 'cases:manage',
CASES_ASSIGN: 'cases:assign',
ASSIGNED_CASES_READ: 'assigned_cases:read',
SERVICE_RECORDS_WRITE: 'service_records:write',
FILES_WRITE: 'files:write',
FOLLOW_UPS_READ: 'follow_ups:read',
FOLLOW_UPS_MANAGE: 'follow_ups:manage',
FOLLOW_UPS_COMPLETE: 'follow_ups:complete',
VOLUNTEERS_READ: 'volunteers:read',
VOLUNTEERS_REVIEW: 'volunteers:review',
PROJECTS_READ: 'projects:read',
PROJECTS_MANAGE: 'projects:manage',
PROJECTS_PUBLISH: 'projects:publish',
ACTIVITIES_READ: 'activities:read',
ACTIVITIES_MANAGE: 'activities:manage',
ACTIVITIES_PUBLISH: 'activities:publish',
REGISTRATIONS_MANAGE: 'registrations:manage',
CONTENT_READ: 'content:read',
CONTENT_MANAGE: 'content:manage',
CONTENT_REVIEW: 'content:review',
NOTIFICATIONS_MANAGE: 'notifications:manage',
STATISTICS_READ: 'statistics:read',
EXPORTS_CREATE: 'exports:create',
} as const;
export type PermissionCode = (typeof PERMISSION_CODES)[keyof typeof PERMISSION_CODES];
export interface AuthUser {
publicId: string;
status: UserStatus;
userType: UserType;
displayName: string | null;
roles: RoleCode[];
permissions: PermissionCode[];
}
export interface AdminLoginRequest {
username: string;
password: string;
}
export interface AdminLoginResponse {
user: AuthUser;
expiresAt: string;
}
export interface WechatLoginRequest {
code: string;
}
export interface WechatLoginResponse {
accessToken: string;
tokenType: 'Bearer';
expiresAt: string;
user: AuthUser;
}
export interface CurrentUserResponse {
user: AuthUser;
}
export interface LogoutResponse {
loggedOut: true;
}
export interface UpdateUserRolesRequest {
roles: RoleCode[];
}
export interface UpdateUserRolesResponse {
userPublicId: string;
roles: RoleCode[];
}
+308
View File
@@ -0,0 +1,308 @@
import type { ServiceApplicationStatus, ServiceCategory } from './service-applications.js';
export const BENEFICIARY_GENDERS = ['FEMALE', 'MALE', 'OTHER', 'UNDISCLOSED'] as const;
export type BeneficiaryGender = (typeof BENEFICIARY_GENDERS)[number];
export const BENEFICIARY_STATUSES = ['ACTIVE', 'INACTIVE', 'ARCHIVED'] as const;
export type BeneficiaryStatus = (typeof BENEFICIARY_STATUSES)[number];
export const SERVICE_CASE_PRIORITIES = ['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const;
export type ServiceCasePriority = (typeof SERVICE_CASE_PRIORITIES)[number];
export const SERVICE_CASE_STATUSES = [
'ASSIGNED',
'IN_SERVICE',
'SERVICE_COMPLETED',
'SUSPENDED',
'CANCELLED',
] as const;
export type ServiceCaseStatus = (typeof SERVICE_CASE_STATUSES)[number];
export const CASE_ASSIGNEE_TYPES = ['STAFF', 'VOLUNTEER'] as const;
export type CaseAssigneeType = (typeof CASE_ASSIGNEE_TYPES)[number];
export const SERVICE_RECORD_TYPES = [
'HOME_VISIT',
'PHONE_CONTACT',
'ESCORT',
'MATERIAL_SUPPORT',
'ACTIVITY',
'OTHER',
] as const;
export type ServiceRecordType = (typeof SERVICE_RECORD_TYPES)[number];
export const BENEFICIARY_CONFIRM_STATUSES = [
'NOT_REQUESTED',
'CONFIRMED',
'PARTIALLY_CONFIRMED',
'DISPUTED',
] as const;
export type BeneficiaryConfirmStatus = (typeof BENEFICIARY_CONFIRM_STATUSES)[number];
export const SERVICE_CASE_STATUS_LABELS: Record<ServiceCaseStatus, string> = {
ASSIGNED: '已指派',
IN_SERVICE: '服务中',
SERVICE_COMPLETED: '服务已完成',
SUSPENDED: '服务暂缓',
CANCELLED: '已取消',
};
export const SERVICE_CASE_PRIORITY_LABELS: Record<ServiceCasePriority, string> = {
LOW: '低',
NORMAL: '一般',
HIGH: '较高',
URGENT: '紧急',
};
export const SERVICE_RECORD_TYPE_LABELS: Record<ServiceRecordType, string> = {
HOME_VISIT: '上门探访',
PHONE_CONTACT: '电话关怀',
ESCORT: '陪同协助',
MATERIAL_SUPPORT: '物资支持',
ACTIVITY: '公益活动',
OTHER: '其他服务',
};
export interface BeneficiaryContactInput {
name: string;
relation: string;
mobile: string;
isGuardian: boolean;
isEmergencyContact: boolean;
}
export interface NewBeneficiaryInput {
displayName: string;
gender?: BeneficiaryGender;
birthYear?: number;
mobile?: string;
regionCode: string;
addressDetail?: string;
keyCareFlag: boolean;
isMinor: boolean;
notes?: string;
contacts: BeneficiaryContactInput[];
tagCodes: string[];
guardianConsentConfirmed?: true;
}
export interface AcceptServiceApplicationRequest {
clientRequestId: string;
beneficiary:
| { mode: 'CREATE'; profile: NewBeneficiaryInput }
| {
mode: 'LINK_EXISTING';
beneficiaryPublicId: string;
manualMatchConfirmed: true;
};
ownerStaffPublicId: string;
collaboratorUserPublicIds: string[];
priority: ServiceCasePriority;
plannedStartAt?: string;
plannedEndAt?: string;
serviceLocation?: string;
summary: string;
}
export interface TransitionServiceApplicationRequest {
targetStatus: Extract<ServiceApplicationStatus, 'PENDING_CONTACT' | 'ASSESSING'>;
reasonCode: string;
}
export interface BeneficiaryContactDetail {
publicId: string;
name: string;
relation: string;
mobile: string;
mobileMasked: string;
isGuardian: boolean;
isEmergencyContact: boolean;
hasConsentRecord: boolean;
}
export interface BeneficiaryTagOption {
code: string;
name: string;
description: string | null;
}
export interface BeneficiaryListItem {
publicId: string;
displayName: string;
gender: BeneficiaryGender | null;
birthYear: number | null;
mobileMasked: string | null;
regionCode: string;
primaryCategory: ServiceCategory;
keyCareFlag: boolean;
isMinor: boolean;
status: BeneficiaryStatus;
tags: BeneficiaryTagOption[];
createdAt: string;
}
export interface BeneficiaryDetail extends BeneficiaryListItem {
mobile: string | null;
addressDetail: string | null;
notes: string | null;
contacts: BeneficiaryContactDetail[];
serviceCaseCount: number;
}
export interface PaginatedBeneficiariesResponse {
items: BeneficiaryListItem[];
page: number;
pageSize: number;
total: number;
}
export interface BeneficiaryDetailResponse {
beneficiary: BeneficiaryDetail;
}
export interface UpdateBeneficiaryRequest extends NewBeneficiaryInput {
status: BeneficiaryStatus;
}
export interface AssigneeOption {
userPublicId: string;
displayName: string;
type: CaseAssigneeType;
department: string | null;
}
export interface CaseAssigneeItem extends AssigneeOption {
role: 'OWNER' | 'COLLABORATOR';
assignedAt: string;
}
export interface ServiceCaseHistoryItem {
status: ServiceCaseStatus;
statusLabel: string;
reasonLabel: string;
createdAt: string;
}
export interface ServiceRecordAttachment {
publicId: string;
filename: string;
mimeType: string;
size: number;
}
export interface ServiceRecordItem {
publicId: string;
serviceStartAt: string;
serviceEndAt: string | null;
serviceType: ServiceRecordType;
serviceTypeLabel: string;
content: string;
result: string;
followUpSuggestion: string | null;
beneficiaryConfirmStatus: BeneficiaryConfirmStatus;
createdByDisplayName: string;
revision: number;
attachments: ServiceRecordAttachment[];
createdAt: string;
updatedAt: string;
}
export interface ServiceCaseListItem {
publicId: string;
caseNo: string;
applicationPublicId: string;
applicationNo: string;
beneficiaryPublicId: string;
beneficiaryDisplayName: string;
category: ServiceCategory;
priority: ServiceCasePriority;
priorityLabel: string;
status: ServiceCaseStatus;
statusLabel: string;
ownerDisplayName: string;
plannedStartAt: string | null;
createdAt: string;
}
export interface ServiceCaseDetail extends ServiceCaseListItem {
regionCode: string;
beneficiaryMobile: string | null;
beneficiaryMobileMasked: string | null;
serviceLocation: string | null;
summary: string;
resultSummary: string | null;
followUpPlan: string | null;
completedAt: string | null;
assignees: CaseAssigneeItem[];
history: ServiceCaseHistoryItem[];
records: ServiceRecordItem[];
}
export interface PaginatedServiceCasesResponse {
items: ServiceCaseListItem[];
page: number;
pageSize: number;
total: number;
}
export interface ServiceCaseDetailResponse {
serviceCase: ServiceCaseDetail;
}
export interface AcceptServiceApplicationResponse {
serviceCase: ServiceCaseDetail;
idempotentReplay: boolean;
}
export interface ReplaceCaseAssigneesRequest {
collaboratorUserPublicIds: string[];
}
export interface CreateServiceRecordRequest {
clientRequestId: string;
serviceStartAt: string;
serviceEndAt?: string;
serviceType: ServiceRecordType;
content: string;
result: string;
followUpSuggestion?: string;
beneficiaryConfirmStatus: BeneficiaryConfirmStatus;
attachmentPublicIds: string[];
}
export interface UpdateServiceRecordRequest extends Omit<
CreateServiceRecordRequest,
'clientRequestId'
> {
expectedRevision: number;
}
export interface ServiceRecordResponse {
record: ServiceRecordItem;
idempotentReplay?: boolean;
}
export interface CreateUploadIntentRequest {
clientRequestId: string;
filename: string;
mimeType: 'image/jpeg' | 'image/png' | 'image/webp';
size: number;
sha256?: string;
}
export interface CreateUploadIntentResponse {
file: ServiceRecordAttachment;
uploadUrl: string;
uploadHeaders: Record<string, string>;
expiresAt: string;
idempotentReplay: boolean;
}
export interface ConfirmUploadResponse {
file: ServiceRecordAttachment;
}
export interface DownloadUrlResponse {
downloadUrl: string;
expiresAt: string;
}
@@ -0,0 +1,143 @@
export const CONTENT_POST_TYPES = [
'ORGANIZATION_INTRO',
'PUBLIC_WELFARE_NEWS',
'SERVICE_STORY',
'NOTICE',
'FAQ',
] as const;
export type ContentPostType = (typeof CONTENT_POST_TYPES)[number];
export const CONTENT_POST_TYPE_LABELS: Record<ContentPostType, string> = {
ORGANIZATION_INTRO: '机构介绍',
PUBLIC_WELFARE_NEWS: '公益动态',
SERVICE_STORY: '服务故事',
NOTICE: '通知公告',
FAQ: '常见问题',
};
export const CONTENT_POST_STATUSES = ['DRAFT', 'PENDING_REVIEW', 'PUBLISHED', 'ARCHIVED'] as const;
export type ContentPostStatus = (typeof CONTENT_POST_STATUSES)[number];
export const CONTENT_POST_STATUS_LABELS: Record<ContentPostStatus, string> = {
DRAFT: '草稿',
PENDING_REVIEW: '待审核',
PUBLISHED: '已发布',
ARCHIVED: '已归档',
};
export interface ContentPostListItem {
publicId: string;
type: ContentPostType;
title: string;
summary: string;
status: ContentPostStatus;
revision: number;
publishedAt: string | null;
updatedAt: string;
}
export interface ContentStatusHistoryItem {
fromStatus: ContentPostStatus | null;
toStatus: ContentPostStatus;
reasonCode: string;
note: string | null;
createdAt: string;
}
export interface ContentPostDetail extends ContentPostListItem {
content: string;
coverFilePublicId: string | null;
privacyReviewedAt: string | null;
reviewedAt: string | null;
history: ContentStatusHistoryItem[];
}
export interface PaginatedContentPostsResponse {
items: ContentPostListItem[];
page: number;
pageSize: number;
total: number;
}
export interface ContentPostDetailResponse {
post: ContentPostDetail;
idempotentReplay?: boolean;
}
export interface CreateContentPostRequest {
clientRequestId: string;
type: ContentPostType;
title: string;
summary: string;
content: string;
coverFilePublicId?: string;
}
export interface UpdateContentPostRequest extends Omit<
CreateContentPostRequest,
'clientRequestId'
> {
expectedRevision: number;
}
export interface TransitionContentPostRequest {
clientRequestId: string;
targetStatus: ContentPostStatus;
note: string;
privacyReviewed?: true;
}
export const NOTIFICATION_TYPES = [
'APPLICATION_SUBMITTED',
'APPLICATION_ACCEPTED',
'SERVICE_ARRANGED',
'SERVICE_COMPLETED',
'FOLLOW_UP_PENDING',
'ACTIVITY_REGISTRATION_RESULT',
] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
export const NOTIFICATION_TYPE_LABELS: Record<NotificationType, string> = {
APPLICATION_SUBMITTED: '申请已提交',
APPLICATION_ACCEPTED: '申请已受理',
SERVICE_ARRANGED: '服务已安排',
SERVICE_COMPLETED: '服务已完成',
FOLLOW_UP_PENDING: '待回访',
ACTIVITY_REGISTRATION_RESULT: '活动报名结果',
};
export interface NotificationItem {
publicId: string;
type: NotificationType;
title: string;
body: string;
resourceType: string | null;
resourcePublicId: string | null;
readAt: string | null;
createdAt: string;
}
export interface PaginatedNotificationsResponse {
items: NotificationItem[];
page: number;
pageSize: number;
total: number;
unreadCount: number;
}
export interface NotificationPreferenceItem {
type: NotificationType;
inAppEnabled: boolean;
wechatEnabled: boolean;
authorizedAt: string | null;
}
export interface NotificationPreferencesResponse {
items: NotificationPreferenceItem[];
wechatAdapterConfigured: boolean;
}
export interface UpdateNotificationPreferencesRequest {
items: Array<{ type: NotificationType; inAppEnabled: boolean; wechatEnabled: boolean }>;
}
export interface NotificationDeliveryItem {
publicId: string;
notificationPublicId: string;
type: NotificationType;
channel: 'WECHAT_SUBSCRIPTION';
status: 'PENDING' | 'PROCESSING' | 'SENT' | 'FAILED' | 'DEAD';
attemptCount: number;
nextAttemptAt: string;
lastAttemptAt: string | null;
errorCode: string | null;
createdAt: string;
}
export interface PaginatedNotificationDeliveriesResponse {
items: NotificationDeliveryItem[];
page: number;
pageSize: number;
total: number;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import {
FOLLOW_UP_RESULT_CODES,
FOLLOW_UP_RESULT_LABELS,
FOLLOW_UP_STATUS_LABELS,
FOLLOW_UP_TYPES,
FOLLOW_UP_TYPE_LABELS,
} from './follow-ups.js';
describe('follow-up contracts', () => {
it('keeps every persisted enum value mapped to a user-facing label', () => {
expect(Object.keys(FOLLOW_UP_TYPE_LABELS).sort()).toEqual([...FOLLOW_UP_TYPES].sort());
expect(Object.keys(FOLLOW_UP_RESULT_LABELS).sort()).toEqual([...FOLLOW_UP_RESULT_CODES].sort());
expect(FOLLOW_UP_STATUS_LABELS.PENDING).toBe('待回访');
});
});
+122
View File
@@ -0,0 +1,122 @@
import type { ServiceCategory } from './service-applications.js';
export const FOLLOW_UP_TYPES = ['SERVICE_COMPLETION', 'KEY_CARE', 'MANUAL'] as const;
export type FollowUpType = (typeof FOLLOW_UP_TYPES)[number];
export const FOLLOW_UP_STATUSES = ['PENDING', 'COMPLETED', 'CANCELLED'] as const;
export type FollowUpStatus = (typeof FOLLOW_UP_STATUSES)[number];
export const FOLLOW_UP_RESULT_CODES = [
'SATISFIED',
'GENERALLY_SATISFIED',
'UNSATISFIED',
'NEED_MORE_HELP',
'UNREACHABLE',
'OTHER',
] as const;
export type FollowUpResultCode = (typeof FOLLOW_UP_RESULT_CODES)[number];
export const FOLLOW_UP_TYPE_LABELS: Record<FollowUpType, string> = {
SERVICE_COMPLETION: '服务完成回访',
KEY_CARE: '重点关爱回访',
MANUAL: '人工回访',
};
export const FOLLOW_UP_STATUS_LABELS: Record<FollowUpStatus, string> = {
PENDING: '待回访',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
export const FOLLOW_UP_RESULT_LABELS: Record<FollowUpResultCode, string> = {
SATISFIED: '满意',
GENERALLY_SATISFIED: '基本满意',
UNSATISFIED: '不满意',
NEED_MORE_HELP: '需要继续帮扶',
UNREACHABLE: '未能联系',
OTHER: '其他',
};
export interface CompleteServiceCaseRequest {
clientRequestId: string;
resultSummary: string;
followUpPlan: string;
followUpDueAt: string;
}
export interface CompleteServiceCaseResponse {
serviceCasePublicId: string;
serviceCaseStatus: 'SERVICE_COMPLETED';
applicationStatus: 'FOLLOW_UP_PENDING';
followUp: FollowUpListItem;
idempotentReplay: boolean;
}
export interface CreateFollowUpRequest {
clientRequestId: string;
beneficiaryPublicId: string;
ownerStaffPublicId: string;
serviceCasePublicId?: string;
followUpType: Extract<FollowUpType, 'KEY_CARE' | 'MANUAL'>;
dueAt: string;
reason: string;
}
export interface CompleteFollowUpRequest {
clientRequestId: string;
resultCode: FollowUpResultCode;
resultNote: string;
continuationConsentConfirmed?: true;
continuationDescription?: string;
}
export interface FollowUpListItem {
publicId: string;
followUpNo: string;
beneficiaryPublicId: string;
beneficiaryDisplayName: string;
beneficiaryKeyCareFlag: boolean;
category: ServiceCategory;
serviceCasePublicId: string | null;
serviceCaseNo: string | null;
ownerStaffPublicId: string;
ownerDisplayName: string;
dueAt: string;
followUpType: FollowUpType;
followUpTypeLabel: string;
status: FollowUpStatus;
statusLabel: string;
resultCode: FollowUpResultCode | null;
resultLabel: string | null;
createdAt: string;
completedAt: string | null;
}
export interface FollowUpDetail extends FollowUpListItem {
reason: string;
resultNote: string | null;
continuedApplicationPublicId: string | null;
continuedApplicationNo: string | null;
}
export interface FollowUpDetailResponse {
followUp: FollowUpDetail;
}
export interface FollowUpMutationResponse extends FollowUpDetailResponse {
idempotentReplay: boolean;
}
export interface PaginatedFollowUpsResponse {
items: FollowUpListItem[];
page: number;
pageSize: number;
total: number;
}
export interface FollowUpWorkbenchSummary {
dueToday: number;
overdue: number;
dueThisWeek: number;
keyCarePending: number;
}
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { HEALTH_STATUS } from './health.js';
describe('health contract', () => {
it('uses a stable healthy status', () => {
expect(HEALTH_STATUS).toBe('ok');
});
});
+7
View File
@@ -0,0 +1,7 @@
export const HEALTH_STATUS = 'ok' as const;
export interface HealthResponse {
status: typeof HEALTH_STATUS;
service: 'api';
timestamp: string;
}
+198
View File
@@ -0,0 +1,198 @@
export { API_ERROR_CODES } from './api.js';
export type { ApiErrorCode, ApiErrorResponse, ApiSuccessResponse } from './api.js';
export { PERMISSION_CODES, ROLE_CODES, USER_STATUSES, USER_TYPES } from './auth.js';
export type {
AdminLoginRequest,
AdminLoginResponse,
AuthUser,
CurrentUserResponse,
LogoutResponse,
PermissionCode,
RoleCode,
UserStatus,
UserType,
UpdateUserRolesRequest,
UpdateUserRolesResponse,
WechatLoginRequest,
WechatLoginResponse,
} from './auth.js';
export { HEALTH_STATUS } from './health.js';
export type { HealthResponse } from './health.js';
export {
APPLICATION_APPLY_MODE_LABELS,
APPLICATION_APPLY_MODES,
SERVICE_APPLICATION_STATUS_LABELS,
SERVICE_APPLICATION_STATUSES,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
} from './service-applications.js';
export type { StatisticsOverview } from './statistics.js';
export {
CONTENT_POST_STATUSES,
CONTENT_POST_STATUS_LABELS,
CONTENT_POST_TYPES,
CONTENT_POST_TYPE_LABELS,
NOTIFICATION_TYPES,
NOTIFICATION_TYPE_LABELS,
} from './content-notifications.js';
export type {
ContentPostType,
ContentPostStatus,
ContentPostListItem,
ContentPostDetail,
ContentStatusHistoryItem,
PaginatedContentPostsResponse,
ContentPostDetailResponse,
CreateContentPostRequest,
UpdateContentPostRequest,
TransitionContentPostRequest,
NotificationType,
NotificationItem,
PaginatedNotificationsResponse,
NotificationPreferenceItem,
NotificationPreferencesResponse,
UpdateNotificationPreferencesRequest,
NotificationDeliveryItem,
PaginatedNotificationDeliveriesResponse,
} from './content-notifications.js';
export {
FOLLOW_UP_RESULT_CODES,
FOLLOW_UP_RESULT_LABELS,
FOLLOW_UP_STATUSES,
FOLLOW_UP_STATUS_LABELS,
FOLLOW_UP_TYPES,
FOLLOW_UP_TYPE_LABELS,
} from './follow-ups.js';
export {
ACTIVITY_STATUSES,
ACTIVITY_STATUS_LABELS,
CHECK_IN_STATUSES,
CHECK_IN_STATUS_LABELS,
PROJECT_STATUSES,
PROJECT_STATUS_LABELS,
REGISTRATION_STATUSES,
REGISTRATION_STATUS_LABELS,
VOLUNTEER_SKILL_CODES,
VOLUNTEER_SKILL_LABELS,
VOLUNTEER_STATUSES,
VOLUNTEER_STATUS_LABELS,
} from './participation.js';
export type {
ActivityCheckInStatus,
ActivityDetail,
ActivityDetailResponse,
ActivityListItem,
ActivityRegistrationItem,
ActivityRegistrationResponse,
ActivityRegistrationStatus,
ActivityStatus,
AdminVolunteerDetailResponse,
AdminVolunteerListItem,
ApplyVolunteerRequest,
CheckInActivityRegistrationRequest,
CreateActivityRequest,
CreateMediaUploadIntentRequest,
CreateProjectRequest,
PaginatedActivitiesResponse,
PaginatedActivityRegistrationsResponse,
PaginatedProjectsResponse,
PaginatedVolunteersResponse,
ProjectDetail,
ProjectDetailResponse,
ProjectListItem,
ProjectStatus,
RecordServiceMinutesRequest,
RegisterActivityRequest,
ReplaceProjectCasesRequest,
RestoreVolunteerRequest,
ReviewActivityRegistrationRequest,
ReviewVolunteerRequest,
TransitionActivityRequest,
TransitionProjectRequest,
UpdateActivityRequest,
UpdateProjectRequest,
VolunteerProfile,
VolunteerProfileResponse,
VolunteerSkillCode,
VolunteerStatus,
} from './participation.js';
export type {
CompleteFollowUpRequest,
CompleteServiceCaseRequest,
CompleteServiceCaseResponse,
CreateFollowUpRequest,
FollowUpDetail,
FollowUpDetailResponse,
FollowUpListItem,
FollowUpMutationResponse,
FollowUpResultCode,
FollowUpStatus,
FollowUpType,
FollowUpWorkbenchSummary,
PaginatedFollowUpsResponse,
} from './follow-ups.js';
export {
BENEFICIARY_CONFIRM_STATUSES,
BENEFICIARY_GENDERS,
BENEFICIARY_STATUSES,
CASE_ASSIGNEE_TYPES,
SERVICE_CASE_PRIORITIES,
SERVICE_CASE_PRIORITY_LABELS,
SERVICE_CASE_STATUSES,
SERVICE_CASE_STATUS_LABELS,
SERVICE_RECORD_TYPES,
SERVICE_RECORD_TYPE_LABELS,
} from './case-management.js';
export type {
AcceptServiceApplicationRequest,
AcceptServiceApplicationResponse,
AssigneeOption,
BeneficiaryConfirmStatus,
BeneficiaryContactDetail,
BeneficiaryContactInput,
BeneficiaryDetail,
BeneficiaryDetailResponse,
BeneficiaryGender,
BeneficiaryListItem,
BeneficiaryStatus,
BeneficiaryTagOption,
CaseAssigneeItem,
CaseAssigneeType,
ConfirmUploadResponse,
CreateServiceRecordRequest,
CreateUploadIntentRequest,
CreateUploadIntentResponse,
DownloadUrlResponse,
NewBeneficiaryInput,
PaginatedBeneficiariesResponse,
PaginatedServiceCasesResponse,
ReplaceCaseAssigneesRequest,
ServiceCaseDetail,
ServiceCaseDetailResponse,
ServiceCaseHistoryItem,
ServiceCaseListItem,
ServiceCasePriority,
ServiceCaseStatus,
ServiceRecordAttachment,
ServiceRecordItem,
ServiceRecordResponse,
ServiceRecordType,
TransitionServiceApplicationRequest,
UpdateBeneficiaryRequest,
UpdateServiceRecordRequest,
} from './case-management.js';
export type {
AdminServiceApplicationQuery,
ApplicationApplyMode,
CreateServiceApplicationRequest,
CreateServiceApplicationResponse,
PaginatedServiceApplicationsResponse,
ServiceApplicationDetail,
ServiceApplicationDetailResponse,
ServiceApplicationHistoryItem,
ServiceApplicationListItem,
ServiceApplicationStatus,
ServiceCategoriesResponse,
ServiceCategory,
ServiceCategoryOption,
} from './service-applications.js';
+363
View File
@@ -0,0 +1,363 @@
import type { ServiceCategory } from './service-applications.js';
export const VOLUNTEER_STATUSES = [
'APPLIED',
'APPROVED',
'REJECTED',
'SUSPENDED',
'INACTIVE',
] as const;
export type VolunteerStatus = (typeof VOLUNTEER_STATUSES)[number];
export const VOLUNTEER_STATUS_LABELS: Record<VolunteerStatus, string> = {
APPLIED: '待审核',
APPROVED: '已认证',
REJECTED: '未通过',
SUSPENDED: '已暂停',
INACTIVE: '已停用',
};
export const VOLUNTEER_SKILL_CODES = [
'ELDER_CARE',
'CHILD_CARE',
'DISABILITY_SUPPORT',
'EVENT_SUPPORT',
'FIRST_AID',
'PHOTOGRAPHY',
] as const;
export type VolunteerSkillCode = (typeof VOLUNTEER_SKILL_CODES)[number];
export const VOLUNTEER_SKILL_LABELS: Record<VolunteerSkillCode, string> = {
ELDER_CARE: '老年关怀',
CHILD_CARE: '儿童陪伴',
DISABILITY_SUPPORT: '残障支持',
EVENT_SUPPORT: '活动协助',
FIRST_AID: '急救技能',
PHOTOGRAPHY: '摄影记录',
};
export const PROJECT_STATUSES = ['DRAFT', 'PUBLISHED', 'COMPLETED'] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const PROJECT_STATUS_LABELS: Record<ProjectStatus, string> = {
DRAFT: '草稿',
PUBLISHED: '已发布',
COMPLETED: '已完成',
};
export const ACTIVITY_STATUSES = [
'DRAFT',
'PUBLISHED',
'REGISTRATION_CLOSED',
'IN_PROGRESS',
'COMPLETED',
'CANCELLED',
] as const;
export type ActivityStatus = (typeof ACTIVITY_STATUSES)[number];
export const ACTIVITY_STATUS_LABELS: Record<ActivityStatus, string> = {
DRAFT: '草稿',
PUBLISHED: '报名中',
REGISTRATION_CLOSED: '报名已截止',
IN_PROGRESS: '进行中',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
export const REGISTRATION_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'CANCELLED'] as const;
export type ActivityRegistrationStatus = (typeof REGISTRATION_STATUSES)[number];
export const REGISTRATION_STATUS_LABELS: Record<ActivityRegistrationStatus, string> = {
PENDING: '待审核',
APPROVED: '报名成功',
REJECTED: '未通过',
CANCELLED: '已取消',
};
export const CHECK_IN_STATUSES = ['NOT_CHECKED_IN', 'CHECKED_IN', 'ABSENT'] as const;
export type ActivityCheckInStatus = (typeof CHECK_IN_STATUSES)[number];
export const CHECK_IN_STATUS_LABELS: Record<ActivityCheckInStatus, string> = {
NOT_CHECKED_IN: '未签到',
CHECKED_IN: '已签到',
ABSENT: '未到场',
};
export interface ApplyVolunteerRequest {
clientRequestId: string;
expectedRevision?: number;
realName: string;
mobile: string;
regionCode: string;
availabilityNote: string;
skillCodes: VolunteerSkillCode[];
interestCategories: ServiceCategory[];
privacyConsent: true;
}
export interface VolunteerStatusHistoryItem {
status: VolunteerStatus;
statusLabel: string;
reasonLabel: string;
note: string | null;
createdAt: string;
}
export interface VolunteerProfile {
publicId: string;
realName: string;
mobile: string;
mobileMasked: string;
regionCode: string;
availabilityNote: string;
skillCodes: VolunteerSkillCode[];
skillLabels: string[];
interestCategories: ServiceCategory[];
status: VolunteerStatus;
statusLabel: string;
revision: number;
totalServiceMinutes: number;
completedActivityCount: number;
history: VolunteerStatusHistoryItem[];
createdAt: string;
}
export interface VolunteerProfileResponse {
volunteer: VolunteerProfile | null;
idempotentReplay?: boolean;
}
export interface AdminVolunteerListItem {
publicId: string;
realName: string;
mobileMasked: string;
regionCode: string;
skillLabels: string[];
interestCategories: ServiceCategory[];
status: VolunteerStatus;
statusLabel: string;
totalServiceMinutes: number;
completedActivityCount: number;
createdAt: string;
}
export interface PaginatedVolunteersResponse {
items: AdminVolunteerListItem[];
page: number;
pageSize: number;
total: number;
}
export interface AdminVolunteerDetailResponse {
volunteer: VolunteerProfile;
}
export interface ReviewVolunteerRequest {
clientRequestId: string;
targetStatus: Extract<VolunteerStatus, 'APPROVED' | 'REJECTED' | 'SUSPENDED'>;
note: string;
}
export interface RestoreVolunteerRequest {
clientRequestId: string;
targetStatus: 'APPROVED';
note: string;
}
export interface CreateProjectRequest {
clientRequestId: string;
name: string;
category: ServiceCategory;
summary: string;
content: string;
coverFilePublicId?: string;
managerUserPublicId: string;
startDate: string;
endDate: string;
targetPeople?: number;
isPublic: boolean;
sortOrder: number;
}
export interface UpdateProjectRequest extends Omit<CreateProjectRequest, 'clientRequestId'> {
expectedRevision: number;
}
export interface ProjectStatusHistoryItem {
status: ProjectStatus;
statusLabel: string;
reasonLabel: string;
createdAt: string;
}
export interface ProjectListItem {
publicId: string;
projectNo: string;
name: string;
category: ServiceCategory;
summary: string;
coverImageUrl: string | null;
managerUserPublicId: string;
managerDisplayName: string;
startDate: string;
endDate: string;
targetPeople: number | null;
status: ProjectStatus;
statusLabel: string;
isPublic: boolean;
sortOrder: number;
revision: number;
activityCount: number;
serviceCaseCount: number;
createdAt: string;
}
export interface ProjectDetail extends ProjectListItem {
content: string;
coverFilePublicId: string | null;
history: ProjectStatusHistoryItem[];
}
export interface ProjectDetailResponse {
project: ProjectDetail;
idempotentReplay?: boolean;
}
export interface PaginatedProjectsResponse {
items: ProjectListItem[];
page: number;
pageSize: number;
total: number;
}
export interface TransitionProjectRequest {
clientRequestId: string;
targetStatus: Extract<ProjectStatus, 'PUBLISHED' | 'COMPLETED'>;
}
export interface ReplaceProjectCasesRequest {
serviceCasePublicIds: string[];
}
export interface CreateActivityRequest {
clientRequestId: string;
projectPublicId?: string;
title: string;
summary: string;
content: string;
coverFilePublicId?: string;
startAt: string;
endAt: string;
registrationDeadline: string;
locationText: string;
capacity?: number;
}
export interface UpdateActivityRequest extends Omit<CreateActivityRequest, 'clientRequestId'> {
expectedRevision: number;
}
export interface ActivityStatusHistoryItem {
status: ActivityStatus;
statusLabel: string;
reasonLabel: string;
createdAt: string;
}
export interface ActivityListItem {
publicId: string;
activityNo: string;
projectPublicId: string | null;
projectName: string | null;
title: string;
summary: string;
coverImageUrl: string | null;
startAt: string;
endAt: string;
registrationDeadline: string;
locationText: string;
capacity: number | null;
activeRegistrationCount: number;
remainingCapacity: number | null;
status: ActivityStatus;
statusLabel: string;
canRegister: boolean;
revision: number;
createdAt: string;
}
export interface ActivityDetail extends ActivityListItem {
content: string;
coverFilePublicId: string | null;
history: ActivityStatusHistoryItem[];
}
export interface ActivityDetailResponse {
activity: ActivityDetail;
currentUserRegistration: ActivityRegistrationItem | null;
idempotentReplay?: boolean;
}
export interface PaginatedActivitiesResponse {
items: ActivityListItem[];
page: number;
pageSize: number;
total: number;
}
export interface TransitionActivityRequest {
clientRequestId: string;
targetStatus: Exclude<ActivityStatus, 'DRAFT'>;
}
export interface RegisterActivityRequest {
clientRequestId: string;
}
export interface ActivityRegistrationItem {
publicId: string;
activityPublicId: string;
activityTitle: string;
volunteerPublicId: string;
volunteerName: string;
mobileMasked: string;
status: ActivityRegistrationStatus;
statusLabel: string;
checkInStatus: ActivityCheckInStatus;
checkInStatusLabel: string;
serviceMinutes: number | null;
registeredAt: string;
}
export interface ActivityRegistrationResponse {
registration: ActivityRegistrationItem;
idempotentReplay: boolean;
}
export interface PaginatedActivityRegistrationsResponse {
items: ActivityRegistrationItem[];
page: number;
pageSize: number;
total: number;
}
export interface ReviewActivityRegistrationRequest {
clientRequestId: string;
targetStatus: Extract<ActivityRegistrationStatus, 'APPROVED' | 'REJECTED'>;
note: string;
}
export interface CheckInActivityRegistrationRequest {
clientRequestId: string;
targetStatus: Extract<ActivityCheckInStatus, 'CHECKED_IN' | 'ABSENT'>;
}
export interface RecordServiceMinutesRequest {
clientRequestId: string;
serviceMinutes: number;
}
export interface CreateMediaUploadIntentRequest {
clientRequestId: string;
businessType: 'PROJECT_COVER' | 'ACTIVITY_COVER';
filename: string;
mimeType: 'image/jpeg' | 'image/png' | 'image/webp';
size: number;
sha256?: string;
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import {
APPLICATION_APPLY_MODE_LABELS,
SERVICE_APPLICATION_STATUS_LABELS,
SERVICE_CATEGORIES,
SERVICE_CATEGORY_LABELS,
} from './index.js';
describe('service application contracts', () => {
it('keeps the six approved V1 service categories stable', () => {
expect(SERVICE_CATEGORIES).toHaveLength(6);
expect(SERVICE_CATEGORY_LABELS.OLD_AGE).toBe('关爱老人');
expect(SERVICE_CATEGORY_LABELS.CHILD).toBe('儿童关爱');
});
it('provides user-facing relationship and status labels', () => {
expect(APPLICATION_APPLY_MODE_LABELS.FAMILY).toBe('帮家人申请');
expect(SERVICE_APPLICATION_STATUS_LABELS.PENDING_CONTACT).toBe('工作人员正在联系');
});
});
@@ -0,0 +1,149 @@
export const SERVICE_CATEGORIES = [
'OLD_AGE',
'WOMEN',
'CHILD',
'DISABILITY',
'LIFE_DIFFICULTY',
'OTHER',
] as const;
export type ServiceCategory = (typeof SERVICE_CATEGORIES)[number];
export const APPLICATION_APPLY_MODES = [
'SELF',
'FAMILY',
'COMMUNITY',
'VOLUNTEER',
'OTHER',
] as const;
export type ApplicationApplyMode = (typeof APPLICATION_APPLY_MODES)[number];
export const SERVICE_APPLICATION_STATUSES = [
'SUBMITTED',
'PENDING_CONTACT',
'ASSESSING',
'REJECTED',
'OUT_OF_SCOPE',
'REFERRED',
'ACCEPTED',
'SERVICE_PLANNED',
'IN_SERVICE',
'SERVICE_COMPLETED',
'FOLLOW_UP_PENDING',
'CLOSED',
'CANCELLED',
'SUSPENDED',
] as const;
export type ServiceApplicationStatus = (typeof SERVICE_APPLICATION_STATUSES)[number];
export const SERVICE_CATEGORY_LABELS: Record<ServiceCategory, string> = {
OLD_AGE: '关爱老人',
WOMEN: '困境妇女',
CHILD: '儿童关爱',
DISABILITY: '残障帮扶',
LIFE_DIFFICULTY: '生活困难',
OTHER: '其他服务',
};
export const APPLICATION_APPLY_MODE_LABELS: Record<ApplicationApplyMode, string> = {
SELF: '为自己申请',
FAMILY: '帮家人申请',
COMMUNITY: '社区或机构代办',
VOLUNTEER: '志愿者代办',
OTHER: '其他代办',
};
export const SERVICE_APPLICATION_STATUS_LABELS: Record<ServiceApplicationStatus, string> = {
SUBMITTED: '已提交',
PENDING_CONTACT: '工作人员正在联系',
ASSESSING: '需求评估中',
REJECTED: '暂未通过',
OUT_OF_SCOPE: '超出当前服务范围',
REFERRED: '已协助转介',
ACCEPTED: '已受理',
SERVICE_PLANNED: '服务安排中',
IN_SERVICE: '服务进行中',
SERVICE_COMPLETED: '服务已完成',
FOLLOW_UP_PENDING: '等待回访',
CLOSED: '服务已结束',
CANCELLED: '申请已取消',
SUSPENDED: '服务暂缓',
};
export interface ServiceCategoryOption {
code: ServiceCategory;
label: string;
description: string;
}
export interface ServiceCategoriesResponse {
items: ServiceCategoryOption[];
}
export interface CreateServiceApplicationRequest {
clientRequestId: string;
serviceCategory: ServiceCategory;
applyMode: ApplicationApplyMode;
beneficiaryName: string;
contactMobile: string;
regionCode: string;
description: string;
preferredContactTime?: string;
needsHomeVisit?: boolean;
mobilityDifficulty?: boolean;
privacyConsent: true;
}
export interface ServiceApplicationHistoryItem {
status: ServiceApplicationStatus;
statusLabel: string;
reasonLabel: string;
createdAt: string;
}
export interface ServiceApplicationListItem {
publicId: string;
applicationNo: string;
serviceCategory: ServiceCategory;
serviceCategoryLabel: string;
applyMode: ApplicationApplyMode;
applyModeLabel: string;
beneficiaryName: string;
contactMobileMasked: string;
regionCode: string;
status: ServiceApplicationStatus;
statusLabel: string;
submittedAt: string;
}
export interface ServiceApplicationDetail extends ServiceApplicationListItem {
contactMobile: string;
description: string;
preferredContactTime: string | null;
needsHomeVisit: boolean | null;
mobilityDifficulty: boolean | null;
history: ServiceApplicationHistoryItem[];
}
export interface CreateServiceApplicationResponse {
application: ServiceApplicationDetail;
idempotentReplay: boolean;
}
export interface PaginatedServiceApplicationsResponse {
items: ServiceApplicationListItem[];
page: number;
pageSize: number;
total: number;
}
export interface ServiceApplicationDetailResponse {
application: ServiceApplicationDetail;
}
export interface AdminServiceApplicationQuery {
page?: number;
pageSize?: number;
applicationNo?: string;
serviceCategory?: ServiceCategory;
status?: ServiceApplicationStatus;
}
+15
View File
@@ -0,0 +1,15 @@
import type { ServiceCategory } from './service-applications.js';
export interface StatisticsOverview {
period: { startDate: string; endDate: string };
applicationCount: number;
acceptedCount: number;
completedServiceCount: number;
servicePersonTimes: number;
uniqueBeneficiaryCount: number;
volunteerCount: number;
activityCount: number;
volunteerMinutes: number;
byCategory: Array<{ category: ServiceCategory; count: number }>;
monthly: Array<{ month: string; applications: number; completedServices: number }>;
generatedAt: string;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "dist",
"sourceMap": true,
"types": []
},
"exclude": ["src/**/*.spec.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts"]
}