refactor: export full Gregorian range, add timezone fallback, cleanup rate limit, configurable schema path
- GenerateMonthlyReport now takes explicit Gregorian start/end range, computed via monthGregorianRange, so Jalali/Hijri months spanning two Gregorian months are fully covered in exports. - Added loadLocation helper (UTC fallback on error) and replaced all 11 ignored-error call sites in handlers + export. - Added periodicCleanup goroutine in NewHandler to prevent unbounded growth of the rateLimit map. - schemaPath is now read from SCHEMA_PATH env var (init), defaulting to /app/db/schema.sql. - Migrated sendDayView to use sendOrEdit helper, removing duplicated send/edit branching. - Removed dead code: userYearMonthToGregorian (unused), the old startOffset function (replaced). - Updated README with calendar docs, new env vars, full project tree. - Updated .env.example and docker-compose.yml with SCHEMA_PATH. - Simplified Makefile: added fmt, tidy, check, run-dev targets; removed broken Make-glob prerequisite pattern.
This commit is contained in:
@@ -25,12 +25,28 @@ type Handler struct {
|
||||
}
|
||||
|
||||
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler {
|
||||
return &Handler{
|
||||
h := &Handler{
|
||||
Bot: bot,
|
||||
DB: store,
|
||||
AllowedUsers: allowed,
|
||||
lastMsgTime: make(map[int64]time.Time),
|
||||
}
|
||||
go h.periodicCleanup()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) periodicCleanup() {
|
||||
for {
|
||||
time.Sleep(10 * time.Second)
|
||||
h.mu.Lock()
|
||||
cutoff := time.Now().Add(-rateLimitInterval * 2)
|
||||
for uid, t := range h.lastMsgTime {
|
||||
if t.Before(cutoff) {
|
||||
delete(h.lastMsgTime, uid)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
type actionFunc func(int64) (string, error)
|
||||
@@ -222,7 +238,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
|
||||
}
|
||||
|
||||
if last != nil && last.EventType == "in" {
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
|
||||
if lastTime.Format("2006-01-02") == now.Format("2006-01-02") {
|
||||
@@ -230,7 +246,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
wt := day.CurrentWorkTypeID
|
||||
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
|
||||
@@ -254,7 +270,7 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
|
||||
if last.EventType == "out" {
|
||||
return "", errors.New("Already clocked out")
|
||||
}
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
|
||||
return "", err
|
||||
@@ -726,7 +742,7 @@ func (h *Handler) handleEditMsg(msg *tgbotapi.Message) {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
date = userDateToGregorian(date, user.Calendar)
|
||||
h.sendDayView(msg.Chat.ID, 0, user, date, loc, true)
|
||||
}
|
||||
@@ -737,7 +753,7 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
|
||||
if data == "history" {
|
||||
h.editCalendar(chatID, msgID, 0, 0)
|
||||
@@ -816,7 +832,7 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
|
||||
if year == 0 || month == 0 {
|
||||
@@ -919,15 +935,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"),
|
||||
),
|
||||
)
|
||||
if isNewMsg || msgID == 0 {
|
||||
reply := tgbotapi.NewMessage(chatID, text)
|
||||
reply.ReplyMarkup = &kb
|
||||
h.Bot.Send(reply)
|
||||
} else {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
h.sendOrEdit(chatID, msgID, text, &kb)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -963,15 +971,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
|
||||
))
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
|
||||
|
||||
if isNewMsg || msgID == 0 {
|
||||
reply := tgbotapi.NewMessage(chatID, text)
|
||||
reply.ReplyMarkup = &kb
|
||||
h.Bot.Send(reply)
|
||||
} else {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
h.sendOrEdit(chatID, msgID, text, &kb)
|
||||
}
|
||||
|
||||
func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
||||
@@ -1184,25 +1184,32 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
|
||||
// Default to current month in user's calendar
|
||||
cy, cm := now.Year(), int(now.Month())
|
||||
switch user.Calendar {
|
||||
case "jalali":
|
||||
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
||||
case "hijri":
|
||||
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
|
||||
}
|
||||
|
||||
// Parse optional YYYY-MM argument (in user's calendar)
|
||||
gy, gm := now.Year(), int(now.Month())
|
||||
if len(msg.Text) > 7 {
|
||||
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &gy, &gm); n == 2 {
|
||||
if gy < 0 || gm < 1 || gm > 12 {
|
||||
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 {
|
||||
if cy < 0 || cm < 1 || cm > 12 {
|
||||
h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
|
||||
return
|
||||
}
|
||||
gy, gm = userYearMonthToGregorian(gy, gm, user.Calendar)
|
||||
}
|
||||
}
|
||||
|
||||
h.sendExportDirect(msg.Chat.ID, user, gy, gm)
|
||||
h.sendExportDirect(msg.Chat.ID, user, cy, cm)
|
||||
}
|
||||
|
||||
func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
|
||||
func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) {
|
||||
// Check if clocked in
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err == nil && last != nil && last.EventType == "in" {
|
||||
@@ -1210,8 +1217,20 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm)
|
||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
|
||||
loc := loadLocation(user.Timezone)
|
||||
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
||||
start, err := time.Parse("2006-01-02", startStr)
|
||||
if err != nil {
|
||||
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||
}
|
||||
end, err := time.Parse("2006-01-02", endStr)
|
||||
if err != nil {
|
||||
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
||||
end = startOfMonth.AddDate(0, 1, -1)
|
||||
}
|
||||
|
||||
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
|
||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
|
||||
if err != nil {
|
||||
slog.Error("generate report", "error", err)
|
||||
h.sendText(chatID, "Error generating report")
|
||||
@@ -1219,7 +1238,7 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
|
||||
}
|
||||
slog.Info("report generated", "bytes", len(data))
|
||||
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
|
||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
|
||||
Bytes: data,
|
||||
})
|
||||
if _, err := h.Bot.Send(doc); err != nil {
|
||||
@@ -1233,7 +1252,7 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
cy, cm := now.Year(), int(now.Month())
|
||||
switch user.Calendar {
|
||||
@@ -1313,16 +1332,20 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
|
||||
h.Bot.Send(edit)
|
||||
return
|
||||
}
|
||||
// Convert from user's calendar to Gregorian for export
|
||||
gy, gm := y, m
|
||||
switch user.Calendar {
|
||||
case "jalali":
|
||||
gy, gm, _ = jalaliToGregorian(y, m, 1)
|
||||
case "hijri":
|
||||
gy, gm, _ = hijriToGregorian(y, m, 1)
|
||||
// Convert from user's calendar to full Gregorian range
|
||||
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
||||
start, err := time.Parse("2006-01-02", startStr)
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Error processing date")
|
||||
return
|
||||
}
|
||||
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm, "cal", user.Calendar, "cal_y", y, "cal_m", m)
|
||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
|
||||
end, err := time.Parse("2006-01-02", endStr)
|
||||
if err != nil {
|
||||
h.sendText(chatID, "Error processing date")
|
||||
return
|
||||
}
|
||||
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
|
||||
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
|
||||
if err != nil {
|
||||
slog.Error("generate report", "error", err)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report")
|
||||
@@ -1337,7 +1360,7 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
|
||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
||||
Bytes: data,
|
||||
})
|
||||
if _, err := h.Bot.Send(doc); err != nil {
|
||||
@@ -1367,7 +1390,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
|
||||
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err == nil && last != nil && last.EventType == "in" {
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04")
|
||||
text += fmt.Sprintf("\nStatus: working since %s", t)
|
||||
} else {
|
||||
@@ -1376,7 +1399,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
|
||||
|
||||
events, err := h.DB.EventsForDayByDayID(day.ID)
|
||||
if err == nil && len(events) > 0 {
|
||||
loc, _ := time.LoadLocation(user.Timezone)
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
y, m, d := now.Date()
|
||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
|
||||
|
||||
Reference in New Issue
Block a user