Skip to content

Commit 39c3e34

Browse files
authored
PR: [OFFNAL-83]: 공휴일 Open API 엔드포인트 추가 및 공휴일 데이터 캐싱 로직 추가 (#128)
* feat: [OFFNAL-83] Open API Endpoint 선언 * feat: [OFFNAL-83]: 사용자의 기기에 공휴일 정보 캐싱 * feat: [OFFNAL-83]: api 호출 클라이언트 수정 * feat: [OFFNAL-83]: 트랜잭션 원자성을 띄도록 수정
1 parent 6cf4c6b commit 39c3e34

25 files changed

+419
-84
lines changed

App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
1414
import { PortalProvider } from '@gorhom/portal'
1515
import { enableScreens } from 'react-native-screens'
1616
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'
17+
import dayjs from 'dayjs'
18+
import { getHolidayDateSetUseCase } from './src/infrastructure/di/Dependencies'
1719

1820
enableScreens()
1921

@@ -25,6 +27,11 @@ function App() {
2527
try {
2628
await initializeDataBaseTables()
2729
console.log('DB tables created')
30+
try {
31+
await getHolidayDateSetUseCase.execute(dayjs().year().toString())
32+
} catch (error) {
33+
console.error('Error caching holiday data', error)
34+
}
2835
} catch (error) {
2936
console.error('Error creating DB tables', error)
3037
} finally {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import dayjs from 'dayjs'
2+
import { HolidayRepository } from '../../domain/repositories/HolidayRepository'
3+
import { HolidayDao } from '../../infrastructure/local/dao/HolidayDao'
4+
import { OpenApiService } from '../../infrastructure/remote/api/OpenApiService'
5+
import { HolidayEntity } from '../../infrastructure/local/entities/HolidayEntity'
6+
7+
export class HolidayRepositoryImpl implements HolidayRepository {
8+
constructor(
9+
private holidayDao: HolidayDao,
10+
private openApiService: OpenApiService
11+
) {}
12+
13+
async ensureYearCached(year: string): Promise<void> {
14+
const hasCompleteCache = await this.holidayDao.hasCompleteYearCache(year)
15+
16+
if (hasCompleteCache) {
17+
return
18+
}
19+
20+
const response = await this.openApiService.getRestDeInfo(year)
21+
const items: Array<
22+
Omit<HolidayEntity, 'id' | 'year' | 'createdAt' | 'updatedAt'>
23+
> = response.body.items.item.map(item => ({
24+
dateName: item.dateName,
25+
locdate: item.locdate,
26+
}))
27+
28+
await this.holidayDao.replaceHolidayItems(
29+
year,
30+
items,
31+
response.body.totalCount
32+
)
33+
}
34+
35+
async getHolidayDateSet(year: string): Promise<Set<string>> {
36+
await this.ensureYearCached(year)
37+
return this.holidayDao.getHolidayDateSetByYear(year)
38+
}
39+
40+
async isHoliday(date: string): Promise<boolean> {
41+
const targetDate = dayjs(date)
42+
const year = targetDate.year().toString()
43+
const holidayDateSet = await this.getHolidayDateSet(year)
44+
45+
return holidayDateSet.has(targetDate.format('YYYY-MM-DD'))
46+
}
47+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface HolidayRepository {
2+
ensureYearCached(year: string): Promise<void>
3+
getHolidayDateSet(year: string): Promise<Set<string>>
4+
isHoliday(date: string): Promise<boolean>
5+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import dayjs from 'dayjs'
2+
import { HolidayRepository } from '../../repositories/HolidayRepository'
3+
4+
export class GetHolidayDateSetUseCase {
5+
constructor(private holidayRepository: HolidayRepository) {}
6+
7+
async execute(
8+
year: string = dayjs().year().toString()
9+
): Promise<Set<string>> {
10+
return this.holidayRepository.getHolidayDateSet(year)
11+
}
12+
}

src/infrastructure/di/Dependencies.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,19 @@ import { TeamCalendarService } from '../remote/api/TeamCalendarService'
3535
import { ScheduleInfoService } from '../remote/api/ScheduleInfoService'
3636
import { ScheduleInfoRepositoryImpl } from '../../data/impl/ScheduleInfoRepositoryImpl'
3737
import { HealthRepositoryImpl } from '../../data/impl/HealthRepositoryImpl'
38+
import { HolidayDao } from '../local/dao/HolidayDao'
39+
import { OpenApiService } from '../remote/api/OpenApiService'
40+
import { HolidayRepositoryImpl } from '../../data/impl/HolidayRepositoryImpl'
3841
import { Platform } from 'react-native'
3942
import { IosHealthDataSource } from '../dataSource/IosHealthDataSource'
4043
import { AndroidHealthDataSource } from '../dataSource/AndroidHealthDataSource'
4144
import { DeleteAllTodosUseCase } from '../../domain/usecases/todos/DeleteAllTodosUseCase'
45+
import { GetHolidayDateSetUseCase } from '../../domain/usecases/holiday/GetHolidayDateSetUseCase'
4246

4347
// 1. 구체적인 데이터 소스 인스턴스 생성
4448
const todoDao = new TodoDao()
4549
const memoDao = new MemoDao()
50+
const holidayDao = new HolidayDao()
4651

4752
export const ocrService = new OcrService()
4853
export const organizationService = new OrganizationService()
@@ -54,6 +59,7 @@ export const todoService = new TodoService()
5459
export const memoService = new MemoService()
5560
export const authService = new AuthService()
5661
export const teamCalendarService = new TeamCalendarService()
62+
export const openApiService = new OpenApiService()
5763

5864
// 2. 구체적인 리포지토리 구현체 인스턴스 생성 (TodoDao 주입)
5965
export const todoRepository = new TodoRepositoryImpl(todoDao)
@@ -71,6 +77,10 @@ export const scheduleInfoRepository = new ScheduleInfoRepositoryImpl(
7177
)
7278
export const homeRepository = new HomeRepositoryImpl(homeService)
7379
export const memberRepository = new MemberRepositoryImpl(memberService)
80+
export const holidayRepository = new HolidayRepositoryImpl(
81+
holidayDao,
82+
openApiService
83+
)
7484

7585
const dataSource =
7686
Platform.OS === 'ios'
@@ -100,3 +110,6 @@ export const getToDosByDateUseCase = new GetTodosByDateUseCase(todoRepository)
100110
export const getMemosByDateUseCase = new GetMemosByDateUseCase(memoRepository)
101111
export const getMemoByIdUseCase = new GetMemoByIdUseCase(memoRepository)
102112
export const updateMemoUseCase = new UpdateMemoUseCase(memoRepository)
113+
export const getHolidayDateSetUseCase = new GetHolidayDateSetUseCase(
114+
holidayRepository
115+
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { openShifterzDB } from '../ShifterzDB'
2+
import {
3+
HolidayCacheMetaEntity,
4+
HolidayEntity,
5+
} from '../entities/HolidayEntity'
6+
7+
const formatLocdate = (locdate: number): string => {
8+
const value = String(locdate).padStart(8, '0')
9+
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
10+
}
11+
12+
export class HolidayDao {
13+
async getCacheMeta(year: string): Promise<HolidayCacheMetaEntity | null> {
14+
const db = await openShifterzDB()
15+
const [result] = await db.executeSql(
16+
'SELECT year, totalCount, fetchedAt FROM holiday_cache_meta WHERE year = ?;',
17+
[year]
18+
)
19+
20+
if (result.rows.length === 0) {
21+
return null
22+
}
23+
24+
const row = result.rows.item(0) as HolidayCacheMetaEntity
25+
26+
return {
27+
year: row.year,
28+
totalCount: Number(row.totalCount),
29+
fetchedAt: Number(row.fetchedAt),
30+
}
31+
}
32+
33+
async hasCompleteYearCache(year: string): Promise<boolean> {
34+
const meta = await this.getCacheMeta(year)
35+
36+
if (!meta) {
37+
return false
38+
}
39+
40+
const db = await openShifterzDB()
41+
const [result] = await db.executeSql(
42+
'SELECT COUNT(*) AS count FROM holiday_items WHERE year = ?;',
43+
[year]
44+
)
45+
46+
const row = result.rows.item(0) as { count: number | string }
47+
return Number(row.count) === Number(meta.totalCount)
48+
}
49+
50+
async replaceHolidayItems(
51+
year: string,
52+
items: Omit<HolidayEntity, 'id' | 'year' | 'createdAt' | 'updatedAt'>[],
53+
totalCount: number
54+
): Promise<void> {
55+
const db = await openShifterzDB()
56+
const now = Date.now()
57+
58+
await db.transaction(async tx => {
59+
await tx.executeSql('DELETE FROM holiday_items WHERE year = ?;', [year])
60+
await tx.executeSql(
61+
`INSERT OR REPLACE INTO holiday_cache_meta (year, totalCount, fetchedAt) VALUES (?, ?, ?);`,
62+
[year, totalCount, now]
63+
)
64+
65+
for (const item of items) {
66+
await tx.executeSql(
67+
`INSERT INTO holiday_items (year, dateName, locdate) VALUES (?, ?, ?);`,
68+
[year, item.dateName, item.locdate]
69+
)
70+
}
71+
})
72+
}
73+
74+
async getHolidayItemsByYear(year: string): Promise<HolidayEntity[]> {
75+
const db = await openShifterzDB()
76+
const [result] = await db.executeSql(
77+
'SELECT * FROM holiday_items WHERE year = ? ORDER BY locdate ASC;',
78+
[year]
79+
)
80+
81+
const items: HolidayEntity[] = []
82+
for (let i = 0; i < result.rows.length; i++) {
83+
items.push(result.rows.item(i) as HolidayEntity)
84+
}
85+
86+
return items
87+
}
88+
89+
async getHolidayDateSetByYear(year: string): Promise<Set<string>> {
90+
const items = await this.getHolidayItemsByYear(year)
91+
const dateSet = new Set<string>()
92+
93+
items.forEach(item => {
94+
dateSet.add(formatLocdate(item.locdate))
95+
})
96+
97+
return dateSet
98+
}
99+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface HolidayCacheMetaEntity {
2+
year: string
3+
totalCount: number
4+
fetchedAt: number
5+
}
6+
7+
export interface HolidayEntity {
8+
id?: number
9+
year: string
10+
dateName: string
11+
locdate: number
12+
createdAt?: number
13+
updatedAt?: number
14+
}

src/infrastructure/local/initialization.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,28 @@ export const initializeDataBaseTables = async (): Promise<void> => {
3737
BEGIN
3838
UPDATE memos SET updatedAt = (strftime('%s', 'now') * 1000) WHERE id = OLD.id;
3939
END;`,
40+
`CREATE TABLE IF NOT EXISTS holiday_cache_meta (
41+
year TEXT PRIMARY KEY,
42+
totalCount INTEGER NOT NULL,
43+
fetchedAt INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
44+
);`,
45+
`CREATE TABLE IF NOT EXISTS holiday_items (
46+
id INTEGER PRIMARY KEY AUTOINCREMENT,
47+
year TEXT NOT NULL,
48+
dateName TEXT NOT NULL,
49+
locdate INTEGER NOT NULL,
50+
createdAt INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
51+
updatedAt INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
52+
UNIQUE(year, locdate)
53+
);`,
54+
`CREATE INDEX IF NOT EXISTS idx_holiday_items_year ON holiday_items(year);`,
55+
`CREATE INDEX IF NOT EXISTS idx_holiday_items_locdate ON holiday_items(locdate);`,
56+
`CREATE TRIGGER IF NOT EXISTS update_holiday_items_updatedAt
57+
AFTER UPDATE ON holiday_items
58+
FOR EACH ROW
59+
BEGIN
60+
UPDATE holiday_items SET updatedAt = (strftime('%s', 'now') * 1000) WHERE id = OLD.id;
61+
END;`,
4062
]
4163

4264
for (const query of sqlQueries) {

src/infrastructure/local/migrations.ts

Whitespace-only changes.

src/infrastructure/remote/api/AuthService.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,21 @@
11
import { PostLoginWithAppleRequest } from '../request/PostLoginWithAppleRequest'
22
import { PostLoginWithAppleResponse } from '../response/PostLoginWithAppleResponse'
3-
import { PostRefreshTokenResponse } from '../response/PostRefreshTokenResponse'
4-
import api from './axiosInstance'
5-
import { noInterceptorApi } from './noInterceptorAxiosInstance'
3+
import { baseAxiosClient } from '../axios/createBaseAxiosClient'
4+
import { apiAxiosClient } from '../axios/createApiAxiosClient'
65

76
export class AuthService {
87
getLoginUrl = async () => {
98
try {
10-
const response = await api.get('/login/page')
9+
const response = await apiAxiosClient.get('/login/page')
1110
return response.data.location
1211
} catch (error) {
1312
console.error('login/page API 요청 실패:', error)
1413
}
1514
}
1615

1716
loginWithApple = async (requestDto: PostLoginWithAppleRequest) => {
18-
console.log('loginWithApple requestDto:', requestDto)
19-
console.log('loginWithApple requestDto fullName:', requestDto.fullName)
20-
2117
try {
22-
const response = await api.post<PostLoginWithAppleResponse>(
18+
const response = await apiAxiosClient.post<PostLoginWithAppleResponse>(
2319
'/login/apple',
2420
requestDto
2521
)
@@ -31,7 +27,7 @@ export class AuthService {
3127

3228
// private
3329
private tokenReissueHelper = async (
34-
axiosInstance: typeof api,
30+
axiosInstance: typeof apiAxiosClient,
3531
refreshToken: string,
3632
instanceName: string
3733
): Promise<{ accessToken: string; refreshToken: string }> => {
@@ -52,20 +48,24 @@ export class AuthService {
5248
}
5349

5450
tokenReissue = async (refreshToken: string) => {
55-
return this.tokenReissueHelper(api, refreshToken, 'with interceptor')
51+
return this.tokenReissueHelper(
52+
apiAxiosClient,
53+
refreshToken,
54+
'with interceptor'
55+
)
5656
}
5757

5858
tokenReissueWithNoInterceptor = async (refreshToken: string) => {
5959
return this.tokenReissueHelper(
60-
noInterceptorApi,
60+
baseAxiosClient,
6161
refreshToken,
6262
'no interceptor'
6363
)
6464
}
6565

6666
tokenLogOut = async () => {
6767
try {
68-
await api.post('/tokens/logout')
68+
await apiAxiosClient.post('/tokens/logout')
6969
} catch (error) {
7070
throw error
7171
}

0 commit comments

Comments
 (0)