Design Document — Anti-Social Forces Check Application
1. Architecture Overview
graph TB
subgraph Client["Client (Browser)"]
UI["Next.js App Router<br/>React Server Components + Client Islands"]
end
UI -->|HTTPS| Backend
subgraph Backend["Next.js Backend (Bun)"]
direction TB
subgraph Handlers[" "]
direction LR
API["API Routes<br/>(REST)"]
SA["Server<br/>Actions"]
BG["Background Jobs<br/>(Bun worker threads)"]
end
subgraph Services["Service Layer"]
direction LR
CRS["CheckRequestService"]
SS["ScreeningService"]
NS["NotificationSvc"]
end
subgraph Infra[" "]
direction LR
Prisma["Prisma ORM"]
subgraph Clients["External API Clients"]
HB["HoujinBangouClient"]
ED["EdinetClient"]
KP["KanpouClient"]
FSA["FsaClient"]
MLIT["MlitClient"]
GS["GoogleSearchClient"]
NT["NikkeiTelecomHelper"]
end
end
Handlers --> Services
Services --> Infra
end
Prisma --> DB[("PostgreSQL")]
NS --> SMTP["Email / SMTP"]
Design Principles
- SOLID: Each external API client is a single-responsibility injectable service
- DI: All external dependencies injected — mockable for testing
- SSOT: Corporate number (法人番号) is the single source of truth for entity identity
- KISS: No premature abstraction — build the 3 screening tiers incrementally
2. Project Structure
anti-social-check/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx # Root layout (Japanese locale)
│ │ ├── page.tsx # Dashboard
│ │ ├── checks/
│ │ │ ├── page.tsx # Check request list
│ │ │ ├── new/page.tsx # Create check request
│ │ │ ├── [id]/
│ │ │ │ ├── page.tsx # Check request detail
│ │ │ │ ├── screening/page.tsx # Screening execution view
│ │ │ │ └── results/page.tsx # Results & determination
│ │ │ └── import/page.tsx # Bulk CSV import
│ │ ├── ledger/
│ │ │ └── page.tsx # 検索結果一覧表 view
│ │ ├── monitoring/
│ │ │ └── page.tsx # Annual re-check dashboard
│ │ ├── admin/
│ │ │ ├── keywords/page.tsx # Negative keyword management
│ │ │ └── users/page.tsx # User/role management
│ │ └── api/
│ │ ├── checks/route.ts # Check CRUD
│ │ ├── screening/route.ts # Trigger screening
│ │ ├── houjin/route.ts # Corporate number lookup proxy
│ │ ├── edinet/route.ts # EDINET proxy
│ │ ├── osint/route.ts # OSINT checks proxy
│ │ └── export/route.ts # Excel/PDF export
│ ├── services/
│ │ ├── check-request.service.ts
│ │ ├── screening.service.ts # Orchestrates all screening tiers
│ │ ├── notification.service.ts
│ │ └── export.service.ts
│ ├── clients/ # External API clients (injected)
│ │ ├── houjin-bangou.client.ts # National Tax Agency API
│ │ ├── edinet.client.ts # EDINET API
│ │ ├── kanpou.client.ts # Official Gazette search
│ │ ├── fsa.client.ts # Financial Services Agency
│ │ ├── mlit.client.ts # Ministry of Land
│ │ ├── google-search.client.ts # Google Custom Search API
│ │ └── nikkei-telecom.helper.ts # Query builder (no direct API)
│ ├── db/
│ │ ├── schema.prisma
│ │ └── seed.ts # Default keywords, roles
│ ├── types/
│ │ ├── check.ts
│ │ ├── screening.ts
│ │ ├── entity.ts
│ │ └── api-responses.ts
│ ├── lib/
│ │ ├── auth.ts # Session/auth utilities
│ │ ├── keywords.ts # Default negative keyword set
│ │ └── query-builder.ts # Build search strings from keywords + entity
│ └── components/
│ ├── check-request-form.tsx
│ ├── screening-panel.tsx
│ ├── results-table.tsx
│ ├── status-badge.tsx
│ ├── entity-card.tsx
│ └── dashboard-widgets.tsx
├── prisma/
│ └── migrations/
├── public/
├── tests/
│ ├── services/
│ ├── clients/
│ └── e2e/
├── .env.example
├── bunfig.toml
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
├── package.json
└── Dockerfile
3. Data Model
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Users & Roles ─────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
name String
role Role
createdAt DateTime @default(now())
createdChecks CheckRequest[] @relation("CreatedBy")
assignedChecks CheckRequest[] @relation("AssignedTo")
auditLogs AuditLog[]
}
enum Role {
SALES // 営業担当 — create requests
LEGAL // グループ法務担当 — perform searches
FF_LEGAL // FF法務担当 — review escalated hits
COMPLIANCE // コンプライアンス責任者 — final approvals
ADMIN // System admin
}
// ─── Check Requests ────────────────────────────────────────
model CheckRequest {
id String @id @default(cuid())
trackingNumber String @unique // e.g. "ASC-2026-0001"
status CheckStatus @default(DRAFT)
// Target company info
companyName String
companyAddress String
representativeName String
companyType CompanyType
officerInfoDisclosed Boolean @default(false)
// Resolved identity
corporateNumber String? // 13-digit 法人番号 (null for sole proprietors)
// Determination
determination Determination?
determinationComment String?
determinedAt DateTime?
// Relations
entities CheckEntity[]
attachments Attachment[]
auditLogs AuditLog[]
createdById String
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
assignedToId String?
assignedTo User? @relation("AssignedTo", fields: [assignedToId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
nextCheckDue DateTime? // Annual re-check date
archivedAt DateTime?
@@index([status])
@@index([corporateNumber])
@@index([nextCheckDue])
}
enum CheckStatus {
DRAFT
SUBMITTED
IN_REVIEW // Legal is performing searches
AWAITING_FF_LEGAL // Escalated — hits found
COMPLETED
ARCHIVED
}
enum CompanyType {
LISTED // 上場
UNLISTED_DISCLOSED // 未上場・役員開示あり
UNLISTED_UNDISCLOSED // 未上場・役員開示なし
SOLE_PROPRIETOR // 個人事業主
}
enum Determination {
NO_CONCERNS // 取引開始に懸念ない
TRANSACTION_REFUSED // 取引謝絶
}
// ─── Check Entities (each person/org to screen) ────────────
model CheckEntity {
id String @id @default(cuid())
checkRequestId String
checkRequest CheckRequest @relation(fields: [checkRequestId], references: [id], onDelete: Cascade)
entityType EntityType
name String // Person or company name
nameKana String? // Reading in kana for disambiguation
// Screening results per source
screeningResults ScreeningResult[]
createdAt DateTime @default(now())
@@index([checkRequestId])
}
enum EntityType {
COMPANY // 法人
REPRESENTATIVE // 代表者
OFFICER // 役員
SHAREHOLDER // 主要株主
}
// ─── Screening Results (one per entity per source) ─────────
model ScreeningResult {
id String @id @default(cuid())
entityId String
entity CheckEntity @relation(fields: [entityId], references: [id], onDelete: Cascade)
source ScreeningSource
status ScreeningStatus @default(PENDING)
// Result data
hitCount Int? // Number of matches found
summary String? // Brief description of findings
rawResponse Json? // API response metadata (no copyrighted content)
searchQuery String? // The query that was used
// For Nikkei Telecom escalation
escalatedToFfLegal Boolean @default(false)
ffLegalReview String? // FF Legal's findings
ffLegalReviewerId String?
ffLegalReviewedAt DateTime?
executedAt DateTime?
createdAt DateTime @default(now())
@@unique([entityId, source])
@@index([status])
}
enum ScreeningSource {
HOUJIN_BANGOU // 法人番号 API
EDINET // EDINET disclosure
KANPOU // 官報
FSA // 金融庁
MLIT // 国土交通省
NIKKEI_TELECOM // 日経テレコン
INTERNET_SEARCH // Google search
}
enum ScreeningStatus {
PENDING
IN_PROGRESS
CLEAR // No hits
HIT // Matches found — needs review
ESCALATED // Sent to FF Legal
REVIEWED // FF Legal reviewed
SKIPPED // Not applicable for this entity type
}
// ─── Negative Keywords ─────────────────────────────────────
model NegativeKeyword {
id String @id @default(cuid())
word String @unique
category String // e.g. "organized_crime", "criminal", "financial", "drugs"
isActive Boolean @default(true)
createdAt DateTime @default(now())
}
// ─── Attachments ───────────────────────────────────────────
model Attachment {
id String @id @default(cuid())
checkRequestId String
checkRequest CheckRequest @relation(fields: [checkRequestId], references: [id], onDelete: Cascade)
fileName String
fileType String // MIME type
filePath String // Storage path
fileSize Int
createdAt DateTime @default(now())
}
// ─── Audit Log ─────────────────────────────────────────────
model AuditLog {
id String @id @default(cuid())
checkRequestId String?
checkRequest CheckRequest? @relation(fields: [checkRequestId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
action String // e.g. "check.created", "screening.executed", "determination.set"
details Json? // Structured change data
createdAt DateTime @default(now())
@@index([checkRequestId])
@@index([userId])
@@index([createdAt])
}
4. Screening Pipeline Design
The ScreeningService orchestrates the multi-tier check flow:
flowchart TD
Start(["ScreeningService.execute()<br/>Input: CheckRequest with resolved entities"])
Start --> T1
subgraph T1["Tier 1: OSINT (automatic, parallel)"]
direction LR
HB["HoujinBangou<br/>(resolve)"]
KP["Kanpou"]
FSA["FSA"]
MLIT["MLIT"]
end
T1 --> HitCheck{"Any HIT?"}
HitCheck -->|Yes| AutoFlag["Auto-flag alert"]
HitCheck -->|No| T15
AutoFlag --> T15
subgraph T15["Tier 1.5: EDINET (if listed company)"]
EDINET["Fetch officer list → create additional CheckEntities"]
end
T15 --> T2
subgraph T2["Tier 2: Nikkei Telecom + Internet (semi-manual)"]
direction TB
GenNikkei["1. Generate Nikkei Telecom query string"]
GenGoogle["2. Generate Google search URL"]
UserSearch["3. User executes searches, records hit counts"]
Escalate{"Hits > 0<br/>on Nikkei?"}
EscFF["Auto-escalate to FF Legal"]
GenNikkei --> GenGoogle --> UserSearch --> Escalate
Escalate -->|Yes| EscFF
Escalate -->|No| Done2[Continue]
end
T2 --> Det
subgraph Det["Determination"]
Review["All entities screened → Legal records determination"]
Refused{"REFUSED?"}
Approve["Compliance Officer approval required"]
Review --> Refused
Refused -->|Yes| Approve
Refused -->|No| Complete
end
Complete(["Check Complete"])
Approve --> Complete
Tier 1: Automated OSINT Checks
Each client implements a common interface:
interface ScreeningClient {
source: ScreeningSource;
search(entity: { name: string; address?: string; corporateNumber?: string }): Promise<ScreeningOutcome>;
}
type ScreeningOutcome = {
status: "clear" | "hit";
hitCount: number;
summary: string | null;
rawMetadata: Record<string, unknown>;
};
All Tier 1 checks run in parallel via Promise.all() for each entity.
Tier 2: Nikkei Telecom (Query Generation)
Nikkei Telecom does not have a public API. The application generates search queries but does not automate the web UI. The NikkeiTelecomHelper builds the query string:
class NikkeiTelecomHelper {
buildSearchQuery(entityName: string, keywords: NegativeKeyword[]): string {
// Returns: "entityName and (構成員 or 準構成 or 組員 or ...)"
const keywordClause = keywords
.filter(k => k.isActive)
.map(k => k.word)
.join(" or ");
return `${entityName} and (${keywordClause})`;
}
buildNarrowDownQuery(entityName: string): string {
// For the "絞込み" step — just the entity name
return entityName;
}
getLoginUrl(): string {
return "https://t21.nikkei.co.jp/";
}
}
User workflow in the screening UI:
- System displays the generated query and a "Open Nikkei Telecom" button
- User logs in, navigates to article search, executes the pre-saved search condition
- User uses "絞込み" with the entity name shown by the system
- User enters the hit count back into the application
- System routes based on result (0 = clear, >0 = escalate)
Tier 2: Internet Search (URL Generation)
class GoogleSearchClient {
buildSearchUrl(entityName: string, keywords: NegativeKeyword[]): string {
const keywordClause = keywords
.filter(k => k.isActive)
.map(k => k.word)
.join(" OR ");
const query = `"${entityName}" (${keywordClause})`;
return `https://www.google.com/search?q=${encodeURIComponent(query)}`;
}
}
5. External API Integration Details
5.1 National Tax Agency — 法人番号 Web-API
- Endpoint:
https://api.houjin-bangou.nta.go.jp/4/name - Auth: Application ID (free registration)
- Usage: Resolve company name + address → 13-digit corporate number
- Rate limit: Respectful crawl delay (1 req/sec)
5.2 EDINET API
- Endpoint:
https://api.edinet-fsa.go.jp/api/v2/documents - Auth: API key (free registration)
- Usage: Fetch disclosure documents for listed companies; parse officer names from 有価証券報告書
- Format: Returns XBRL/JSON metadata; document content as ZIP
5.3 官報 (Official Gazette)
- Source: Internet Official Gazette (https://kanpou.npb.go.jp/)
- Method: HTTP search with entity name
- Extracts: Bankruptcy (破産), dissolution (解散), civil rehabilitation (民事再生)
5.4 金融庁 (FSA) Administrative Actions
- Source: https://www.fsa.go.jp/ administrative action lists
- Method: Cached lookup — periodically fetch and index published CSV/HTML data
- Extracts: Administrative actions against financial institutions, unlicensed operators
5.5 国土交通省 (MLIT) Negative Information
- Source: https://www.mlit.go.jp/nega-inf/ (ネガティブ情報等検索システム)
- Method: HTTP query with entity name
- Extracts: Construction/real estate violations, license revocations
6. Key UI Screens
画面レイアウトは実際のHTMLファイルで管理しています(SSOT)。以下のリンクからブラウザで確認できます。
6.1 Dashboard (/)
ステータスカード(未着手/審査中/要確認/完了)、期限アラート、最近のチェック一覧を表示するダッシュボード画面。
6.2 Check Request Detail — Screening View (/checks/[id]/screening)
Tier 1(公的DB自動チェック)、EDINET、Tier 2(日経テレコン+Internet手動検索)のスクリーニング結果と操作パネル、最終判定ボタンを含む画面。
7. API Route Design
| Method | Path | Description | Auth |
|---|---|---|---|
| GET | /api/checks |
List check requests (filterable by status, date) | SALES+ |
| POST | /api/checks |
Create new check request | SALES+ |
| GET | /api/checks/:id |
Get check request detail with entities and results | LEGAL+ |
| PATCH | /api/checks/:id |
Update check request (status, determination) | LEGAL+ |
| POST | /api/checks/:id/entities |
Add entities to check request | LEGAL+ |
| POST | /api/checks/import |
Bulk import from CSV | SALES+ |
| POST | /api/screening/:checkId/osint |
Run Tier 1 OSINT checks for all entities | LEGAL+ |
| POST | /api/screening/:checkId/edinet |
Run EDINET check (listed companies) | LEGAL+ |
| POST | /api/screening/:entityId/nikkei-result |
Record Nikkei Telecom search result | LEGAL+ |
| POST | /api/screening/:entityId/internet-result |
Record internet search result | LEGAL+ |
| POST | /api/screening/:entityId/ff-legal-review |
Record FF Legal review findings | FF_LEGAL+ |
| GET | /api/houjin?name=...&address=... |
Corporate number lookup | LEGAL+ |
| GET | /api/edinet?code=... |
EDINET document search | LEGAL+ |
| GET | /api/export/checks/:id/pdf |
Export check result as PDF | LEGAL+ |
| GET | /api/export/ledger?from=...&to=... |
Export results ledger as Excel | LEGAL+ |
| GET | /api/keywords |
List negative keywords | LEGAL+ |
| POST | /api/keywords |
Add/update negative keyword | ADMIN |
| GET | /api/monitoring/due |
List checks due for annual re-check | LEGAL+ |
8. Authentication & Authorization
Authentication
- Session-based auth using
next-auth(orlucia-auth) with email/password - Sessions stored in PostgreSQL
- 30-minute inactivity timeout
Authorization Matrix
| Action | SALES | LEGAL | FF_LEGAL | COMPLIANCE | ADMIN |
|---|---|---|---|---|---|
| Create check request | ✓ | ✓ | ✓ | ✓ | ✓ |
| View own check requests | ✓ | ✓ | ✓ | ✓ | ✓ |
| View all check requests | ✓ | ✓ | ✓ | ✓ | |
| Execute screening | ✓ | ✓ | ✓ | ✓ | |
| Record Nikkei Telecom results | ✓ | ✓ | ✓ | ||
| Review escalated hits | ✓ | ✓ | ✓ | ||
| Set determination | ✓ | ✓ | ✓ | ✓ | |
| Approve transaction refusal | ✓ | ✓ | |||
| Manage keywords | ✓ | ||||
| Manage users | ✓ | ||||
| Export data | ✓ | ✓ | ✓ | ✓ |
9. Negative Keyword Seed Data
Default keyword set derived from the operational procedures, organized by category:
export const DEFAULT_KEYWORDS = [
// Organized crime (暴力団関連)
{ word: "構成員", category: "organized_crime" },
{ word: "準構成", category: "organized_crime" },
{ word: "組員", category: "organized_crime" },
{ word: "組長", category: "organized_crime" },
{ word: "暴力団", category: "organized_crime" },
{ word: "総会屋", category: "organized_crime" },
{ word: "活動家", category: "organized_crime" },
{ word: "やくざ", category: "organized_crime" },
{ word: "ヤクザ", category: "organized_crime" },
{ word: "マフィア", category: "organized_crime" },
{ word: "えせ", category: "organized_crime" },
{ word: "エセ", category: "organized_crime" },
{ word: "テロリスト", category: "organized_crime" },
{ word: "フロント企業", category: "organized_crime" },
{ word: "密接交際者", category: "organized_crime" },
{ word: "仕手筋", category: "organized_crime" },
{ word: "共生者", category: "organized_crime" },
// Criminal investigation (犯罪捜査関連)
{ word: "捜査", category: "criminal" },
{ word: "捜索", category: "criminal" },
{ word: "指名手配", category: "criminal" },
{ word: "逮捕", category: "criminal" },
{ word: "検挙", category: "criminal" },
{ word: "摘発", category: "criminal" },
{ word: "送検", category: "criminal" },
{ word: "起訴", category: "criminal" },
{ word: "訴訟", category: "criminal" },
{ word: "違反", category: "criminal" },
{ word: "告発", category: "criminal" },
{ word: "容疑", category: "criminal" },
{ word: "容疑者", category: "criminal" },
{ word: "被告", category: "criminal" },
{ word: "釈放", category: "criminal" },
{ word: "警察", category: "criminal" },
{ word: "犯罪", category: "criminal" },
// Financial crimes (金融犯罪)
{ word: "罰金", category: "financial" },
{ word: "追徴金", category: "financial" },
{ word: "行政処分", category: "financial" },
{ word: "行政指導", category: "financial" },
{ word: "押収", category: "financial" },
{ word: "申告漏れ", category: "financial" },
{ word: "脱税", category: "financial" },
{ word: "背任", category: "financial" },
{ word: "横領", category: "financial" },
{ word: "悪徳商法", category: "financial" },
{ word: "恐喝", category: "financial" },
{ word: "インサイダー", category: "financial" },
{ word: "相場操縦", category: "financial" },
// Drugs & weapons (薬物・武器)
{ word: "殺人", category: "drugs_weapons" },
{ word: "麻薬", category: "drugs_weapons" },
{ word: "大麻", category: "drugs_weapons" },
{ word: "コカイン", category: "drugs_weapons" },
{ word: "覚醒剤", category: "drugs_weapons" },
{ word: "銃", category: "drugs_weapons" },
{ word: "日本刀", category: "drugs_weapons" },
{ word: "ピストル", category: "drugs_weapons" },
{ word: "とばく", category: "drugs_weapons" },
{ word: "賭博", category: "drugs_weapons" },
] as const;
10. Background Jobs
Using Bun's worker threads for non-blocking operations:
| Job | Trigger | Description |
|---|---|---|
osint-screening |
On check request submission | Runs all Tier 1 OSINT checks in parallel |
annual-recheck-alert |
Daily cron (via Bun.CronJob) | Finds checks due within 30 days, sends reminders |
overdue-check-alert |
Daily cron | Finds in-progress checks older than threshold |
fsa-data-sync |
Weekly cron | Refreshes local cache of FSA administrative action data |
11. Implementation Phases
Phase 1 — Core Foundation (MVP)
- Project scaffolding (Bun + Next.js + Prisma + PostgreSQL)
- Data model and migrations
- User auth with role-based access
- Check request CRUD and workflow state machine
- Entity management (add company, representative, officers)
- Negative keyword management with seed data
Phase 2 — OSINT Screening (Tier 1)
- 法人番号 API client and entity resolution
- EDINET API client and officer extraction for listed companies
- 官報 search client
- 金融庁 administrative action lookup
- 国土交通省 negative information lookup
- Automated screening orchestration with parallel execution
Phase 3 — Nikkei Telecom & Internet Search (Tier 2)
- Nikkei Telecom query generator and UI workflow
- Google search URL generator
- Manual result recording UI
- FF Legal escalation workflow
- Determination recording with mandatory comments on refusal
Phase 4 — Reporting & Monitoring
- 検索結果一覧表 digital ledger with Excel export
- Individual check PDF export for audit evidence
- Dashboard with summary widgets and alerts
- Annual re-check monitoring and notifications
- Overdue check alerts
Phase 5 — Polish & Hardening
- Bulk CSV import
- Email notifications
- Audit log viewer
- Data retention policy enforcement (auto-archive after 5 years)
- E2E tests and load testing
- Docker packaging for deployment
12. Technology Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Runtime | Bun | User requirement; fast startup, native TypeScript |
| Framework | Next.js (App Router) | Server components reduce client JS; built-in API routes |
| ORM | Prisma | Type-safe queries, migration management, PostgreSQL support |
| UI styling | Tailwind CSS | Utility-first, fast iteration, no CSS-in-JS overhead |
| Component library | shadcn/ui | Accessible, unstyled primitives; Japanese text friendly |
| Auth | next-auth v5 | Built-in session management, credential provider |
| PDF export | @react-pdf/renderer | Generate audit PDFs from React components |
| Excel export | exceljs | Match existing spreadsheet format |
| HTTP client | Built-in fetch (Bun) | No extra dependency needed for API calls |
| Testing | Bun test + Playwright | Unit/integration via bun:test, E2E via Playwright |