読み込み中
豆知識
ゲーミフィケーション(XP・実績など)をフロントが動かすための、DB スキーマ・API・業務ロジックの実装仕様です。
本文は英語のエンジニア向け仕様です。
The gamification system encourages user engagement by rewarding actions that align with the app's mission: 「経理から解放されて、本当の仕事をしよう」 (Free yourself from accounting, do the work that matters).
設計時の目標値。現在の実測値は社内ダッシュボードで追跡しており、公開ドキュメントには載せていません。
| Metric | Target |
|---|---|
| User retention (7-day) | +20% |
| Feature discovery | 80% of features explored |
| Daily active users | +15% |
user_gamification_progressMain table storing user's overall progress.
CREATE TABLE user_gamification_progress (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
-- Level & XP
current_level INTEGER NOT NULL DEFAULT 1,
current_xp INTEGER NOT NULL DEFAULT 0,
total_xp_earned INTEGER NOT NULL DEFAULT 0,
-- Streaks
current_login_streak INTEGER NOT NULL DEFAULT 0,
longest_login_streak INTEGER NOT NULL DEFAULT 0,
last_login_date DATE,
-- Statistics
total_time_saved_minutes INTEGER NOT NULL DEFAULT 0,
total_documents_processed INTEGER NOT NULL DEFAULT 0,
total_transactions_reconciled INTEGER NOT NULL DEFAULT 0,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_user_company UNIQUE (user_id, company_id),
CONSTRAINT positive_xp CHECK (current_xp >= 0),
CONSTRAINT positive_level CHECK (current_level >= 1)
);
-- Indexes
CREATE INDEX idx_gamification_user ON user_gamification_progress(user_id);
CREATE INDEX idx_gamification_company ON user_gamification_progress(company_id);
CREATE INDEX idx_gamification_level ON user_gamification_progress(current_level);
-- RLS Policies
ALTER TABLE user_gamification_progress ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own progress" ON user_gamification_progress
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can update own progress" ON user_gamification_progress
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "System can insert progress" ON user_gamification_progress
FOR INSERT WITH CHECK (auth.uid() = user_id);
user_achievementsTracks earned achievements and progress toward tiered achievements.
CREATE TABLE user_achievements (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
achievement_id TEXT NOT NULL,
-- Progress
current_progress INTEGER NOT NULL DEFAULT 0,
current_tier TEXT, -- 'bronze', 'silver', 'gold', 'platinum' or NULL
-- Timestamps
earned_at TIMESTAMPTZ, -- NULL if not yet earned
viewed_at TIMESTAMPTZ, -- When user dismissed notification
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_user_achievement UNIQUE (user_id, company_id, achievement_id),
CONSTRAINT valid_tier CHECK (current_tier IN ('bronze', 'silver', 'gold', 'platinum') OR current_tier IS NULL)
);
-- Indexes
CREATE INDEX idx_achievements_user ON user_achievements(user_id);
CREATE INDEX idx_achievements_company ON user_achievements(company_id);
CREATE INDEX idx_achievements_earned ON user_achievements(earned_at) WHERE earned_at IS NOT NULL;
CREATE INDEX idx_achievements_id ON user_achievements(achievement_id);
-- RLS Policies
ALTER TABLE user_achievements ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own achievements" ON user_achievements
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can update own achievements" ON user_achievements
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "System can insert achievements" ON user_achievements
FOR INSERT WITH CHECK (auth.uid() = user_id);
user_goalsTracks goal progress and recurring goal completion.
CREATE TABLE user_goals (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
goal_id TEXT NOT NULL,
-- Status
status TEXT NOT NULL DEFAULT 'available', -- 'locked', 'available', 'in_progress', 'completed'
-- Progress
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
current_streak INTEGER DEFAULT 0, -- For recurring goals
total_completions INTEGER NOT NULL DEFAULT 0,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_user_goal UNIQUE (user_id, company_id, goal_id),
CONSTRAINT valid_status CHECK (status IN ('locked', 'available', 'in_progress', 'completed'))
);
-- Indexes
CREATE INDEX idx_goals_user ON user_goals(user_id);
CREATE INDEX idx_goals_company ON user_goals(company_id);
CREATE INDEX idx_goals_status ON user_goals(status);
CREATE INDEX idx_goals_completed ON user_goals(completed_at) WHERE completed_at IS NOT NULL;
-- RLS Policies
ALTER TABLE user_goals ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own goals" ON user_goals
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can update own goals" ON user_goals
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "System can insert goals" ON user_goals
FOR INSERT WITH CHECK (auth.uid() = user_id);
xp_events_logAudit log for XP events (for analytics and anti-abuse).
CREATE TABLE xp_events_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
-- Event details
event_type TEXT NOT NULL, -- 'login', 'document_create', etc.
xp_awarded INTEGER NOT NULL,
source_entity_type TEXT, -- 'document', 'transaction', etc.
source_entity_id UUID,
-- Context
metadata JSONB DEFAULT '{}',
-- Timestamp
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Partition by date for performance
event_date DATE NOT NULL DEFAULT CURRENT_DATE
) PARTITION BY RANGE (event_date);
-- Create partitions (example for 2026)
CREATE TABLE xp_events_log_2026_q1 PARTITION OF xp_events_log
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE xp_events_log_2026_q2 PARTITION OF xp_events_log
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
-- Indexes
CREATE INDEX idx_xp_events_user ON xp_events_log(user_id);
CREATE INDEX idx_xp_events_type ON xp_events_log(event_type);
CREATE INDEX idx_xp_events_date ON xp_events_log(event_date);
-- RLS Policies (read-only for users)
ALTER TABLE xp_events_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own XP events" ON xp_events_log
FOR SELECT USING (auth.uid() = user_id);
Store definitions as constants in backend code (not in database) for:
Reference: src/types/gamification-types.ts contains all definitions.
/api/gamification/progressGet user's complete gamification progress.
Response:
{
success: true,
data: {
userId: string,
companyId: string,
// Level & XP
currentLevel: number,
currentXP: number,
xpToNextLevel: number,
totalXPEarned: number,
// Achievements
achievements: Array<{
achievementId: string,
currentProgress: number,
currentTier?: 'bronze' | 'silver' | 'gold' | 'platinum',
earnedAt?: string,
viewedAt?: string
}>,
recentAchievements: Array<...>, // Last 5 earned
// Goals
goals: Array<{
goalId: string,
status: 'locked' | 'available' | 'in_progress' | 'completed',
startedAt?: string,
completedAt?: string,
currentStreak?: number,
totalCompletions: number
}>,
activeGoals: string[], // Goal IDs in progress
completedGoalsCount: number,
// Streaks
currentLoginStreak: number,
longestLoginStreak: number,
lastLoginDate: string,
// Statistics
totalTimeSavedMinutes: number,
totalDocumentsProcessed: number,
totalTransactionsReconciled: number,
// Timestamps
lastUpdatedAt: string,
createdAt: string
}
}
/xpAward XP for an event.
Request:
{
eventType: string, // 'document_create', 'transaction_reconcile', etc.
amount?: number, // Override default XP (for special events)
sourceEntityType?: string,
sourceEntityId?: string,
metadata?: Record<string, unknown>
}
Response:
{
success: true,
data: {
xpAwarded: number,
newTotalXP: number,
leveledUp: boolean,
newLevel?: number
}
}
Business Logic:
XP_REWARDS in types)user_gamification_progressxp_events_log/achievements/progressUpdate achievement progress.
Request:
{
achievementId: string,
progress: number // New progress value (not increment)
}
Response:
{
success: true,
data: {
achievementId: string,
currentProgress: number,
previousTier?: string,
newTier?: string, // If tier upgraded
earned: boolean,
xpAwarded?: number
}
}
Business Logic:
user_achievements/achievements/viewMark achievement notification as viewed.
Request:
{
achievementId: string
}
/goals/startStart working on a goal.
Request:
{
goalId: string
}
Business Logic:
started_at/goals/completeMark a goal as completed.
Request:
{
goalId: string
}
Business Logic:
total_completions for recurring goals/loginRecord a login (called on each session start).
Response:
{
success: true,
data: {
currentStreak: number,
longestStreak: number,
xpAwarded: number,
achievementsUnlocked: string[] // Achievement IDs
}
}
Business Logic:
/leaderboardGet company leaderboard (optional, for future).
Query Params:
period: 'weekly' | 'monthly' | 'all_time'limit: number (default 10)Response:
{
success: true,
data: {
leaderboard: Array<{
rank: number,
userId: string,
userName: string,
avatarUrl?: string,
level: number,
xp: number
}>,
userRank: number // Requesting user's rank
}
}
The backend should automatically award XP when these events occur:
| Event | Trigger | XP | Daily Limit |
|---|---|---|---|
login |
Session start | 5 | 1 |
consecutive_login |
Streak > 1 | 10 × multiplier | 1 |
document_create |
Document created | 20 | 20 |
document_upload |
File uploaded | 15 | 30 |
document_scan |
Receipt scanned | 10 | 50 |
document_approve |
Document approved | 5 | 100 |
transaction_categorize |
Transaction categorized | 5 | 100 |
transaction_reconcile |
Bank reconciliation | 10 | 50 |
transaction_bulk_process |
Bulk processing | 50 | 5 |
report_generate |
Report generated | 30 | 5 |
report_view |
Report viewed | 5 | 10 |
dashboard_view |
KPI dashboard viewed | 2 | 5 |
ai_suggestion_accept |
AI suggestion accepted | 10 | 50 |
ai_coach_question |
Asked AI coach | 15 | 10 |
settings_update |
Settings changed | 25 | 10 |
bank_connect |
Bank account connected | 100 | 3 |
Add XP event calls to existing services:
// Example: In document creation service
async function createDocument(data: CreateDocumentDTO) {
const document = await documentRepository.create(data)
// Award XP for document creation
await gamificationService.awardXP({
userId: data.userId,
eventType: "document_create",
sourceEntityType: "document",
sourceEntityId: document.id,
})
return document
}
Certain achievements should be automatically checked:
| Achievement | Trigger Condition |
|---|---|
first_login |
First successful login |
company_setup |
Company info completed |
fiscal_year_set |
Fiscal year configured |
bank_connected |
First bank account linked |
onboarding_complete |
All setup steps done |
document_creator |
Document count thresholds |
transaction_master |
Transaction count thresholds |
reconciliation_star |
Reconciliation count thresholds |
login_streak |
Login streak thresholds |
time_saved_* |
Time saved milestones |
Track page visits for exploration achievements:
// Routes that unlock exploration achievements
const EXPLORATION_ROUTES = {
"/documents": "explorer_documents",
"/bank-statement": "explorer_bank",
"/bank-reconciliation": "explorer_reconciliation",
"/kpi-dashboard": "explorer_kpi",
"/financial-reports": "explorer_reports",
"/tax-compliance": "explorer_tax",
"/expense-reports": "explorer_expense",
"/file-management": "explorer_files",
"/trading-partners": "explorer_partners",
"/withholding-tax": "explorer_withholding",
"/ai-usage": "explorer_ai_usage",
"/establishment-guide": "explorer_establishment",
}
Use the USER_LEVELS constant from src/types/gamification-types.ts:
function getLevelByXP(xp: number): UserLevel {
for (let i = USER_LEVELS.length - 1; i >= 0; i--) {
if (xp >= USER_LEVELS[i].minXP) {
return USER_LEVELS[i]
}
}
return USER_LEVELS[0]
}
function calculateStreak(lastLoginDate: Date | null, now: Date): {
newStreak: number;
isConsecutive: boolean;
} {
if (!lastLoginDate) {
return { newStreak: 1, isConsecutive: false };
}
const isSameDay = /* same calendar day check */;
if (isSameDay) {
return { newStreak: currentStreak, isConsecutive: false };
}
const isConsecutive = /* yesterday check */;
return {
newStreak: isConsecutive ? currentStreak + 1 : 1,
isConsecutive
};
}
function calculateTier(
progress: number,
thresholds: number[]
): AchievementTier | null {
// Thresholds: [bronze, silver, gold, platinum]
if (progress >= thresholds[3]) return "platinum"
if (progress >= thresholds[2]) return "gold"
if (progress >= thresholds[1]) return "silver"
if (progress >= thresholds[0]) return "bronze"
return null
}
async function canAwardXP(
userId: string,
eventType: XPRewardEvent,
date: Date
): Promise<boolean> {
const config = XP_REWARDS.find((r) => r.event === eventType)
if (!config?.maxDailyOccurrences) return true
const todayCount = await getEventCountForDay(userId, eventType, date)
return todayCount < config.maxDailyOccurrences
}
The frontend maintains an optimistic cache in Zustand. On login:
GET /api/gamification/progressstore.syncWithBackend(data)When actions occur, frontend:
Example flow:
// In document creation hook
const createDocument = async (data) => {
// Optimistic update
gamificationStore.addXP("document_create")
// Create document
const doc = await api.createDocument(data)
// XP is awarded server-side automatically
// Next /progress fetch will sync
}
Frontend handles toast/modal display:
earned: true when achievement unlockedpendingAchievementToastsPOST /achievements/viewSee src/types/gamification-types.ts for:
USER_LEVELS: Level definitions with XP thresholdsACHIEVEMENTS: Achievement definitionsGOALS: Goal definitionsXP_REWARDS: XP event configurationsFor questions about this specification:
src/stores/gamification-store.ts最終更新: January 2026 Version: 1.0
読み込み中
豆知識