diff --git a/datastores/active_views.ts b/datastores/active_views.ts new file mode 100644 index 0000000..62f3094 --- /dev/null +++ b/datastores/active_views.ts @@ -0,0 +1,16 @@ +import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts"; + +const datastore = DefineDatastore( + { + name: "active_views", + primary_key: "view_id", + attributes: { + view_id: { type: Schema.types.string, required: true }, + user_id: { type: Schema.types.string, required: true }, + last_updated_at: { type: Schema.types.number, required: true }, // epoch time in seconds + last_updated_callback_id: { type: Schema.types.string, required: true }, + }, + } as const, +); + +export default datastore; diff --git a/datastores/user_settings.ts b/datastores/user_settings.ts index e4b25a2..875984f 100644 --- a/datastores/user_settings.ts +++ b/datastores/user_settings.ts @@ -12,6 +12,8 @@ const datastore = DefineDatastore( country_id: { type: Schema.types.string, required: false }, // work, work_and_lifelogs app_mode: { type: Schema.types.string, required: false }, + // time offset + offset: { type: Schema.types.number, requried: true }, }, } as const, ); diff --git a/functions/internals/components.ts b/functions/internals/components.ts index 5568c80..3a8a71d 100644 --- a/functions/internals/components.ts +++ b/functions/internals/components.ts @@ -7,6 +7,8 @@ import { todayYYYYMMDD } from "./datetime.ts"; import { AU, AUMapper, + AV, + AVMapper, C, CMapper, L, @@ -25,6 +27,7 @@ import { import { PrivateMetadata } from "./private_metadata.ts"; import { fetchUserDetails } from "./slack_api.ts"; import { AppModeCode } from "./constants.ts"; +import { determineIsDebugMode, determineLogLevel } from "./debug_mode.ts"; export interface ComponentParams { env: Env; @@ -51,6 +54,7 @@ export interface Components { op: DataMapper; au: DataMapper; l: DataMapper; + av: DataMapper; user: string; email: string; settings: SavedAttributes; @@ -65,14 +69,8 @@ export interface Components { export async function injectComponents( { env, token, client, inputs: { user_id }, body }: ComponentParams, ): Promise { - const isDebugMode: boolean = env.DEBUG_MODE !== undefined && ( - env.DEBUG_MODE === "1" || - env.DEBUG_MODE === "T" || - env.DEBUG_MODE === "TRUE" || - env.DEBUG_MODE === "True" || - env.DEBUG_MODE === "true" - ); - const logLevel = isDebugMode ? "DEBUG" : "INFO"; + const isDebugMode: boolean = determineIsDebugMode(env); + const logLevel = determineLogLevel(env); const slackApi = new SlackAPI(token, { logLevel }); const user = user_id; const userInfo = await fetchUserDetails({ slackApi, user: user_id }); @@ -98,31 +96,18 @@ export async function injectComponents( if (settings.user) { language = settings.language; country = settings.country_id; + if (!settings.offset || settings.offset !== timeOffset) { + const attributes = { ...settings }; + attributes.offset = timeOffset; + await us.save({ attributes }); + } } const ph = PHMapper(client, logLevel); - let _holidays: SavedAttributes | undefined; - const holidays = async () => { - if (_holidays) return _holidays; - const year = _yyyymmdd.substring(0, 4); - _holidays = (await ph.findById(`${country}-${year}`)).item; - return _holidays; - }; + const holidays = buildHolidays(ph, country, _yyyymmdd); - let _canAccessAdminFeature: boolean | undefined; const au = AUMapper(client, logLevel); - const canAccessAdminFeature = async () => { - if (_canAccessAdminFeature) return _canAccessAdminFeature; - const items = (await au.findAll({ limit: 1 })).items; - const noAdminUsers = items === undefined || items.length === 0; - if (noAdminUsers) { - _canAccessAdminFeature = true; - return _canAccessAdminFeature; - } - const thisUserCanAccess = (await au.findById(user)).item.user !== undefined; - _canAccessAdminFeature = thisUserCanAccess; - return _canAccessAdminFeature; - }; + const canAccessAdminFeature = buildCanAccessAdminFeature(au, user); return { isDebugMode, @@ -144,8 +129,45 @@ export async function injectComponents( op: OPMapper(client, logLevel), au, l: LMapper(client, logLevel), + av: AVMapper(client, logLevel), holidays, yyyymmdd: _yyyymmdd, offset: timeOffset, }; } + +export function buildCanAccessAdminFeature( + au: DataMapper, + user: string, +): () => Promise { + let _canAccessAdminFeature: boolean | undefined; + const canAccessAdminFeature = async () => { + if (_canAccessAdminFeature) return _canAccessAdminFeature; + const items = (await au.findAll({ limit: 1 })).items; + const noAdminUsers = items === undefined || items.length === 0; + if (noAdminUsers) { + _canAccessAdminFeature = true; + return _canAccessAdminFeature; + } + const thisUserCanAccess = (await au.findById(user)).item.user !== undefined; + _canAccessAdminFeature = thisUserCanAccess; + return _canAccessAdminFeature; + }; + return canAccessAdminFeature; +} + +export function buildHolidays( + ph: DataMapper, + country: string | undefined, + _yyyymmdd: string, +): () => Promise | undefined> { + let _holidays: SavedAttributes | undefined; + const holidays = async () => { + if (country === undefined) return undefined; + if (_holidays) return _holidays; + const year = _yyyymmdd.substring(0, 4); + _holidays = (await ph.findById(`${country}-${year}`)).item; + return _holidays; + }; + return holidays; +} diff --git a/functions/internals/datastore.ts b/functions/internals/datastore.ts index 09bccce..5455ea0 100644 --- a/functions/internals/datastore.ts +++ b/functions/internals/datastore.ts @@ -14,6 +14,7 @@ import AdminUsers from "../../datastores/admin_users.ts"; import Projects from "../../datastores/projects.ts"; import OrganizationPolicies from "../../datastores/organization_policies.ts"; import Lifelogs from "../../datastores/lifelogs.ts"; +import ActiveViews from "../../datastores/active_views.ts"; import { timeToNumber, todayYYYYMMDD } from "./datetime.ts"; import { CountryCode, Label } from "./constants.ts"; @@ -29,12 +30,13 @@ export type AU = typeof AdminUsers.definition; export type P = typeof Projects.definition; export type OP = typeof OrganizationPolicies.definition; export type L = typeof Lifelogs.definition; +export type AV = typeof ActiveViews.definition; // ----------------------------------------- // DataMapper initializer // ----------------------------------------- -function createDataMapper( +function createDataMapper( def: DEF, client: Client, logLevel: LogLevel, @@ -70,6 +72,9 @@ export function OPMapper(client: Client, logLevel: LogLevel): DataMapper { export function LMapper(client: Client, logLevel: LogLevel): DataMapper { return createDataMapper(Lifelogs.definition, client, logLevel); } +export function AVMapper(client: Client, logLevel: LogLevel): DataMapper { + return createDataMapper(ActiveViews.definition, client, logLevel); +} // ----------------------------------------- // TimeEntries @@ -494,3 +499,54 @@ export async function fetchProject( const response = await p.findById(code); return response.item; } + +// ----------------------------------------- +// ActiveViews +// ----------------------------------------- + +interface saveLastActiveViewArgs { + av: DataMapper; + view_id: string; + user_id: string; + callback_id: string; +} +export async function saveLastActiveView( + { av, view_id, user_id, callback_id }: saveLastActiveViewArgs, +) { + const last_updated_at = Math.floor(new Date().getTime() / 1000); + const attributes: Attributes = { + view_id, + user_id, + last_updated_callback_id: callback_id, + last_updated_at, + }; + await av.save({ attributes }); +} + +interface deleteActiveViewArgs { + av: DataMapper; + view_id: string; +} +export async function deleteClosingOneFromActiveViews( + { av, view_id }: deleteActiveViewArgs, +) { + await av.deleteById({ id: view_id }); +} + +interface cleanUpOldActiveViewsArgs { + av: DataMapper; + user_id: string; +} +export async function cleanUpOldActiveViews( + { av, user_id }: cleanUpOldActiveViewsArgs, +) { + const views = (await av.findAllBy({ where: { user_id } })).items; + const oneDayAgo = Math.floor(new Date().getTime() / 1000) - 60 * 60 * 24; + if (views) { + for (const view of views) { + if (view.last_updated_at < oneDayAgo) { + await av.deleteById({ id: view.view_id }); + } + } + } +} diff --git a/functions/internals/debug_mode.ts b/functions/internals/debug_mode.ts new file mode 100644 index 0000000..afac632 --- /dev/null +++ b/functions/internals/debug_mode.ts @@ -0,0 +1,17 @@ +import { Env } from "deno-slack-sdk/types.ts"; + +export function determineIsDebugMode(env: Env): boolean { + const isDebugMode: boolean = env.DEBUG_MODE !== undefined && ( + env.DEBUG_MODE === "1" || + env.DEBUG_MODE === "T" || + env.DEBUG_MODE === "TRUE" || + env.DEBUG_MODE === "True" || + env.DEBUG_MODE === "true" + ); + return isDebugMode; +} + +export function determineLogLevel(env: Env): "DEBUG" | "INFO" { + const logLevel = determineIsDebugMode(env) ? "DEBUG" : "INFO"; + return logLevel; +} diff --git a/functions/internals/views.ts b/functions/internals/views.ts index 8d2b6ab..90e83fd 100644 --- a/functions/internals/views.ts +++ b/functions/internals/views.ts @@ -13,6 +13,7 @@ import { RichTextBlock, SlackAPIClient, StaticSelect, + ViewsUpdateResponse, } from "slack-web-api-client/mod.ts"; import { i18n } from "./i18n.ts"; @@ -110,6 +111,7 @@ export function newView(language: string): ModalView { "type": "modal", "title": TitleMain(language), "close": QuitApp(language), + "notify_on_close": true, "blocks": [], }; } @@ -298,31 +300,29 @@ export async function syncMainView({ isDebugMode, isLifelogEnabled, manualEntryPermitted, -}: syncMainViewArgs) { +}: syncMainViewArgs): Promise { const privateMetadata: MainViewPrivateMetadata = { yyyymmdd }; - await slackApi.views.update({ - view_id: viewId, - view: { - "type": "modal", - "callback_id": CallbackId.MainView, - "private_metadata": JSON.stringify(privateMetadata), - "title": TitleMain(language), - "close": QuitApp(language), - "blocks": await mainViewBlocks({ - isDebugMode, - isLifelogEnabled, - manualEntryPermitted, - entry: entry, - lifelog: lifelog, - offset, - language, - country, - holidays, - canAccessAdminFeature, - yyyymmdd, - }), - }, - }); + const view: ModalView = { + "type": "modal", + "callback_id": CallbackId.MainView, + "private_metadata": JSON.stringify(privateMetadata), + "title": TitleMain(language), + "close": QuitApp(language), + "blocks": await mainViewBlocks({ + isDebugMode, + isLifelogEnabled, + manualEntryPermitted, + entry: entry, + lifelog: lifelog, + offset, + language, + country, + holidays, + canAccessAdminFeature, + yyyymmdd, + }), + }; + return await slackApi.views.update({ view_id: viewId, view }); } interface toMainViewArgs { @@ -569,12 +569,6 @@ export async function mainViewBlocks({ const reportItems = []; if (r && r.work_minutes + r.break_time_hours + r.time_off_minutes > 0) { - if (isDebugMode) { - console.log( - "### The daily report for the main view:\n" + - JSON.stringify(r, null, 2), - ); - } const workDuration = [ hourDuration(r.work_hours, language), minuteDuration(r.work_minutes, language), @@ -1628,8 +1622,8 @@ export async function syncProjectMainView({ projects, slackApi, language, -}: syncProjectMainViewArgs) { - await slackApi.views.update({ +}: syncProjectMainViewArgs): Promise { + return await slackApi.views.update({ view_id: viewId, view: toProjectMainView({ view: newView(language), projects, language }), }); diff --git a/functions/refresh_main_views.ts b/functions/refresh_main_views.ts new file mode 100644 index 0000000..ca63a9e --- /dev/null +++ b/functions/refresh_main_views.ts @@ -0,0 +1,150 @@ +import { DefineFunction, SlackFunction } from "deno-slack-sdk/mod.ts"; +import { SlackAPIClient as SlackAPI } from "slack-web-api-client/mod.ts"; +import { SavedAttributes } from "deno-slack-data-mapper/mod.ts"; + +import { + AUMapper, + AV, + AVMapper, + cleanUpOldActiveViews, + fetchLifelog, + fetchTimeEntry, + LMapper, + OPMapper, + PHMapper, + saveLastActiveView, + TEMapper, + USMapper, +} from "./internals/datastore.ts"; +import { + AppModeCode, + CallbackId, + LanguageCode, +} from "./internals/constants.ts"; +import { isManualEntryPermitted } from "./internals/organization_policies.ts"; +import { newView, toMainView } from "./internals/views.ts"; +import { + determineIsDebugMode, + determineLogLevel, +} from "./internals/debug_mode.ts"; +import { todayYYYYMMDD } from "./internals/datetime.ts"; +import { + buildCanAccessAdminFeature, + buildHolidays, +} from "./internals/components.ts"; + +export const def = DefineFunction({ + callback_id: "refresh_main_views", + title: "Refresh active main views", + source_file: "functions/refresh_main_views.ts", + input_parameters: { properties: {}, required: [] }, + output_parameters: { properties: {}, required: [] }, +}); + +export default SlackFunction(def, async ({ token, env, client }) => { + const isDebugMode: boolean = determineIsDebugMode(env); + const logLevel = determineLogLevel(env); + const slackApi = new SlackAPI(token, { logLevel }); + const [us, av, te, l, op, au, ph] = [ + USMapper(client, logLevel), + AVMapper(client, logLevel), + TEMapper(client, logLevel), + LMapper(client, logLevel), + OPMapper(client, logLevel), + AUMapper(client, logLevel), + PHMapper(client, logLevel), + ]; + + let activeViews: SavedAttributes[] = []; + try { + activeViews = (await av.findAll()).items; + if (activeViews) { + activeViews.sort((a, b) => + a.user_id + a.last_updated_at > b.user_id + b.last_updated_at ? -1 : 1 + ); + } + console.log(`${activeViews.length} active views found`); + if (isDebugMode) { + console.log(activeViews); + } + } catch (e) { + const error = `Failed to fetch active view data (error: ${e})`; + return { error }; + } + if (activeViews) { + const now = Math.floor(new Date().getTime() / 1000); + // this view has been kept open for a while + const minutes = 5 * 60; + const foundUsers: string[] = []; + for (const activeView of activeViews) { + try { + if (foundUsers.includes(activeView.user_id)) { + await av.deleteById({ id: activeView.view_id }); + continue; + } + foundUsers.push(activeView.user_id); + + if (activeView.last_updated_callback_id === CallbackId.MainView) { + if (activeView.last_updated_at < now - minutes) { + const user = activeView.user_id; + const settings = (await us.findById(activeView.user_id)).item; + const language = settings.language || LanguageCode.English; + const country = settings.country_id; + const offset = settings.offset || 0; + const yyyymmdd = todayYYYYMMDD(offset); + const entry = await fetchTimeEntry({ te, user, offset, yyyymmdd }); + const isLifelogEnabled = + settings.app_mode === AppModeCode.WorkAndLifelogs; + const lifelog = isLifelogEnabled + ? await fetchLifelog({ l, user, offset, yyyymmdd }) + : undefined; + const manualEntryPermitted = await isManualEntryPermitted({ op }); + const canAccessAdminFeature = buildCanAccessAdminFeature(au, user); + const holidays = buildHolidays(ph, country, yyyymmdd); + + try { + const result = await slackApi.views.update({ + view_id: activeView.view_id, + view: await toMainView({ + view: newView(language), + entry, + lifelog, + manualEntryPermitted, + isDebugMode, + canAccessAdminFeature, + isLifelogEnabled, + offset, + language, + country, + holidays, + yyyymmdd, + }), + }); + await saveLastActiveView({ + av, + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + }); + } catch (e) { + console.log(`Failed to update an active view: ${e}`); + if (e.message.includes('"not_found"')) { + await av.deleteById({ id: activeView.view_id }); + } + } + } + } + } catch (e) { + console.log( + `Failed to handle ${JSON.stringify(activeView)} due to ${e}`, + ); + } + } + const promises = foundUsers.map((user_id) => + cleanUpOldActiveViews({ av, user_id }) + ); + await Promise.all(promises); + } + + return { outputs: {} }; +}); diff --git a/functions/run_timesheet.ts b/functions/run_timesheet.ts index 6d3a579..ab80834 100644 --- a/functions/run_timesheet.ts +++ b/functions/run_timesheet.ts @@ -47,6 +47,9 @@ import { toUserSettingsView, } from "./internals/views.ts"; import { + AVMapper, + cleanUpOldActiveViews, + deleteClosingOneFromActiveViews, fetchAllActiveProjects, fetchAllCountries, fetchAllMemberMonthTimeEntries, @@ -62,6 +65,7 @@ import { isLifelogRecord, L, P, + saveLastActiveView, saveLifelog, saveProject, saveTimeEntry, @@ -95,6 +99,7 @@ import { } from "./internals/organization_policies.ts"; import { i18n } from "./internals/i18n.ts"; import { fetchUserDetails } from "./internals/slack_api.ts"; +import { determineLogLevel } from "./internals/debug_mode.ts"; export const def = DefineFunction({ callback_id: "run_timesheet", @@ -123,7 +128,12 @@ export default SlackFunction( }, } = args; const components = await injectComponents({ ...args }); - const { language, settings, isDebugMode, isLifelogEnabled } = components; + const { language, settings, isDebugMode, isLifelogEnabled, av } = + components; + + // Await this later to avoid overhead + const cleanUpTask = cleanUpOldActiveViews({ av, user_id }); + let view: ModalView = newView(language); if (!settings.user) { if (isDebugMode) { @@ -161,18 +171,62 @@ export default SlackFunction( ); } try { - await components.slackApi.views.open( + const viewOpen = await components.slackApi.views.open( { trigger_id: interactivity_pointer, view }, ); + await saveLastActiveView({ + view_id: viewOpen.view!.id!, + user_id: components.user, + callback_id: view.callback_id!, + ...components, + }); } catch (e) { const error = `Failed to open a modal to <@${user_id}> (error: ${e.stack})`; console.log(error); return { error }; } + try { + // Await here to avoid bringing extra overhead for the runtime performance + await cleanUpTask; + } catch (e) { + console.log(`Failed to clean up old view data in datastore: ${e}`); + } + return { completed: false }; }, ) + // -------------------------------------------- + // view_closed handlers + // -------------------------------------------- + .addViewClosedHandler([ + CallbackId.AddEntry, + CallbackId.AddLifelog, + CallbackId.AddProject, + CallbackId.AdminMenu, + CallbackId.AdminReportDownload, + CallbackId.Calendar, + CallbackId.EditEntry, + CallbackId.EditProject, + CallbackId.MainView, + CallbackId.ManualEntry, + CallbackId.OrganizationPolicies, + CallbackId.ProjectMainView, + CallbackId.ReportResult, + CallbackId.ReportStart, + CallbackId.StartLifelog, + CallbackId.StartWorkWithProject, + CallbackId.UserSettings, + ], async (args) => { + const { view, client, env } = args; + try { + const logLevel = determineLogLevel(env); + const av = AVMapper(client, logLevel); + await av.deleteById({ id: view.id }); + } catch (e) { + console.log(`Failed to delete view data in datastore due to ${e}`); + } + }) // -------------------------------------------- // Manual Entry // -------------------------------------------- @@ -198,7 +252,13 @@ export default SlackFunction( view = await newAddLifelogView({ ...components }); } const trigger_id = body.interactivity.interactivity_pointer; - await slackApi.views.push({ trigger_id, view }); + const result = await slackApi.views.push({ trigger_id, view }); + await saveLastActiveView({ + callback_id: view.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = @@ -222,23 +282,27 @@ export default SlackFunction( return {}; } const selected = action.selected_option.value; + const view_id = body.view.id; + let view: ModalView | undefined = undefined; if (selected === EntryType.Lifelog) { - await slackApi.views.update({ - view_id: body.view.id, - view: await newAddLifelogView({ ...components }), - }); + view = await newAddLifelogView({ ...components }); + await slackApi.views.update({ view_id, view }); } else { let projects: SavedAttributes

[] = []; if (selected === EntryType.Work) { projects = await fetchAllActiveProjects({ ...components }); } - await slackApi.views.update({ - view_id: body.view.id, - view: await newAddEntryView( - { projects, entryType: selected, ...components }, - ), - }); + view = await newAddEntryView( + { projects, entryType: selected, ...components }, + ); + await slackApi.views.update({ view_id, view }); } + await saveLastActiveView({ + callback_id: view.callback_id!, + view_id, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = @@ -308,8 +372,9 @@ export default SlackFunction( const saved = await saveTimeEntry({ attributes, ...components }); if (saved) { - await syncMainView({ - viewId: view.root_view_id, + const viewId = view.root_view_id; + const result = await syncMainView({ + viewId, manualEntryPermitted, entry: saved, lifelog: isLifelogEnabled @@ -317,6 +382,16 @@ export default SlackFunction( : undefined, ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); } return {}; } catch (e) { @@ -363,8 +438,9 @@ export default SlackFunction( const manualEntryPermitted = await isManualEntryPermitted({ ...components, }); - await syncMainView({ - viewId: view.root_view_id, + const viewId = view.root_view_id; + const result = await syncMainView({ + viewId, manualEntryPermitted, entry: await fetchTimeEntry({ ...components, @@ -374,6 +450,16 @@ export default SlackFunction( ...components, yyyymmdd, // override }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); } return {}; } catch (e) { @@ -393,6 +479,7 @@ export default SlackFunction( const { body, action } = args; const components = await injectComponents({ ...args }); const { user, slackApi, offset } = components; + const viewId = body.view.id; try { const value: string = action.selected_option.value; let entries: string[] = []; @@ -427,13 +514,19 @@ export default SlackFunction( if (idxToDel > -1) entries.splice(idxToDel, 1); entry = await saveTimeEntry({ attributes, ...components }); } - await syncMainView({ - viewId: body.view.id, + const result = await syncMainView({ + viewId, entry, lifelog, manualEntryPermitted, ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); } else if (value.startsWith("finish___")) { // Finish the time entry const sent = value.split("___")[1]; @@ -474,13 +567,19 @@ export default SlackFunction( } } } - await syncMainView({ + const result = await syncMainView({ viewId: body.view.id, entry, lifelog, manualEntryPermitted, ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); } else { if (!manualEntryPermitted) return {}; @@ -496,12 +595,18 @@ export default SlackFunction( projectCodeEnabled = await hasActiveProjects({ ...components }); } } - await slackApi.views.push({ + const result = await slackApi.views.push({ trigger_id: body.interactivity.interactivity_pointer, view: await newEditEntryView( { type: entry.type, entry, projectCodeEnabled, ...components }, ), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); } return {}; } catch (e) { @@ -592,13 +697,24 @@ export default SlackFunction( timeEntry = await saveTimeEntry({ attributes, ...components }); lifelog = await fetchLifelog({ ...components }); } - await syncMainView({ - viewId: view.root_view_id, + const viewId = view.root_view_id; + const result = await syncMainView({ + viewId, manualEntryPermitted, entry: timeEntry, lifelog: lifelog, ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); return {}; } catch (e) { const error = @@ -626,13 +742,19 @@ export default SlackFunction( } = components; try { if (await hasActiveProjects({ ...components })) { - await slackApi.views.push({ + const result = await slackApi.views.push({ trigger_id: body.interactivity.interactivity_pointer, view: toStartWorkWithProjectCodeView({ view: newView(language), ...components, }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); return; } const entry = await fetchTimeEntry({ ...components }); @@ -649,8 +771,9 @@ export default SlackFunction( }), ); const saved = await saveTimeEntry({ attributes, ...components }); - await syncMainView({ - viewId: body.view.id, + const viewId = body.view.id; + const result = await syncMainView({ + viewId, entry: saved, lifelog: isLifelogEnabled ? await fetchLifelog({ ...components }) @@ -660,6 +783,12 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = `Failed to start work (user: ${user}, error: ${e.stack})`; @@ -690,7 +819,8 @@ export default SlackFunction( ); attributes.work_entries!.push(newEntry); const saved = await saveTimeEntry({ attributes, ...components }); - await syncMainView({ + const viewId = body.view.previous_view_id; + const result = await syncMainView({ viewId: body.view.previous_view_id, entry: saved, lifelog: isLifelogEnabled @@ -701,6 +831,16 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); return {}; } catch (e) { const error = @@ -729,8 +869,9 @@ export default SlackFunction( end, // override }); const saved = await saveTimeEntry({ attributes, ...components }); - await syncMainView({ - viewId: body.view.id, + const viewId = body.view.id; + const result = await syncMainView({ + viewId, entry: saved, lifelog: isLifelogEnabled ? await fetchLifelog({ ...components }) @@ -740,6 +881,12 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); } } return {}; @@ -767,8 +914,9 @@ export default SlackFunction( { start: nowHHMM(offset), end: "", project_code: undefined }, )); const saved = await saveTimeEntry({ attributes, ...components }); - await syncMainView({ - viewId: body.view.root_view_id, + const viewId = body.view.root_view_id; + const result = await syncMainView({ + viewId, entry: saved, lifelog: isLifelogEnabled ? await fetchLifelog({ ...components }) @@ -778,6 +926,12 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = @@ -807,8 +961,9 @@ export default SlackFunction( end, // override }); const saved = await saveTimeEntry({ attributes, ...components }); - await syncMainView({ - viewId: body.view.id, + const viewId = body.view.id; + const result = await syncMainView({ + viewId, entry: saved, lifelog: isLifelogEnabled ? await fetchLifelog({ ...components }) @@ -818,6 +973,12 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); } } } @@ -836,10 +997,16 @@ export default SlackFunction( const components = await injectComponents({ ...args }); const { user, slackApi, language } = components; try { - await slackApi.views.push({ + const result = await slackApi.views.push({ trigger_id: body.interactivity.interactivity_pointer, view: toStartLifelogView({ view: newView(language), ...components }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = @@ -871,8 +1038,9 @@ export default SlackFunction( const log: Lifelog = { start: nowHHMM(offset), what_to_do }; attributes.logs.push(serializeEntry(log)); const saved = await saveLifelog({ attributes, ...components }); - await syncMainView({ - viewId: body.view.root_view_id, + const viewId = body.view.root_view_id; + const result = await syncMainView({ + viewId, entry: await fetchTimeEntry({ ...components }), lifelog: saved, manualEntryPermitted: await isManualEntryPermitted({ @@ -880,6 +1048,16 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); return {}; } catch (e) { const error = @@ -908,8 +1086,9 @@ export default SlackFunction( end, // override }); lifelog = await saveLifelog({ attributes, ...components }); - await syncMainView({ - viewId: body.view.id, + const viewId = body.view.id; + const result = await syncMainView({ + viewId, entry: await fetchTimeEntry({ ...components }), lifelog, manualEntryPermitted: await isManualEntryPermitted({ @@ -917,6 +1096,12 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); } } return {}; @@ -960,8 +1145,9 @@ export default SlackFunction( const components = await injectComponents({ ...args }); const { user, isLifelogEnabled } = components; try { - await syncMainView({ - viewId: body.view.id, + const viewId = body.view.id; + const result = await syncMainView({ + viewId, entry: await fetchTimeEntry({ ...components }), lifelog: isLifelogEnabled ? await fetchLifelog({ ...components }) @@ -971,6 +1157,12 @@ export default SlackFunction( }), ...components, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: viewId, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = @@ -1000,7 +1192,7 @@ export default SlackFunction( try { const selectedMenu: string = action.selected_option.value; if (selectedMenu === MenuItem.UserSettings) { - await slackApi.views.push({ + const result = await slackApi.views.push({ trigger_id: interactivity.interactivity_pointer, view: toUserSettingsView({ view: newView(language), @@ -1009,10 +1201,17 @@ export default SlackFunction( ...components, }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); } else if (selectedMenu === MenuItem.BackToToday) { const yyyymmdd = todayYYYYMMDD(offset); - await slackApi.views.update({ - view_id: view.id, + const view_id = view.id; + const result = await slackApi.views.update({ + view_id, view: await toMainView({ view: newView(language), entry: await fetchTimeEntry({ @@ -1032,22 +1231,46 @@ export default SlackFunction( yyyymmdd, // overrite }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id, + user_id: user, + ...components, + }); } else if (selectedMenu === MenuItem.Calendar) { - await slackApi.views.push({ + const result = await slackApi.views.push({ view: toCalendarView(newView(language), offset, language), trigger_id, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); } else if (selectedMenu === MenuItem.MonthlyReport) { - await slackApi.views.push({ + const result = await slackApi.views.push({ view: toReportStartView({ view: newView(language), ...components }), trigger_id, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); } else if (selectedMenu === MenuItem.AdminMenu) { if (!await canAccessAdminFeature()) return {}; - await slackApi.views.push({ + const result = await slackApi.views.push({ view: toAdminMenuView({ view: newView(language), ...components }), trigger_id, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); } return {}; } catch (e) { @@ -1068,7 +1291,7 @@ export default SlackFunction( const language = stateValue(view, BlockId.Language)! .selected_option!.value; const components = await injectComponents({ ...args }); - const { user, isLifelogEnabled } = components; + const { user, isLifelogEnabled, offset } = components; try { let country_id = ""; let app_mode = AppModeCode.Work; @@ -1081,6 +1304,7 @@ export default SlackFunction( language, country_id, app_mode, + offset, }; const saved = await saveUserSettings({ attributes, ...components }); const manualEntryPermitted = await isManualEntryPermitted({ @@ -1091,7 +1315,7 @@ export default SlackFunction( ? await fetchLifelog({ ...components }) : undefined; if (view.root_view_id !== view.id) { - await syncMainView({ + const result = await syncMainView({ viewId: view.root_view_id, entry, lifelog, @@ -1100,9 +1324,25 @@ export default SlackFunction( language: saved.language, isLifelogEnabled: app_mode === AppModeCode.WorkAndLifelogs, }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); return {}; } else { // When an end-user submits the initial UserSettings + await saveLastActiveView({ + callback_id: view.callback_id, + view_id: view.id, + user_id: user, + ...components, + }); return { response_action: "update", view: await toMainView({ @@ -1135,7 +1375,7 @@ export default SlackFunction( const components = await injectComponents({ ...args }); const { user, language, slackApi, isLifelogEnabled } = components; try { - await slackApi.views.update({ + const result = await slackApi.views.update({ view_id: body.view.root_view_id, view: await toMainView({ view: newView(language), @@ -1156,6 +1396,16 @@ export default SlackFunction( yyyymmdd, // overrite with the sent one }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); return {}; } catch (e) { const error = @@ -1187,6 +1437,12 @@ export default SlackFunction( const lifelogs = includeLifelogs ? await fetchMonthLifelogs({ yyyymm, ...components }) : []; + await saveLastActiveView({ + callback_id: view.callback_id, + view_id: view.id, + user_id: user, + ...components, + }); return { response_action: "update", view: await toReportResultView({ @@ -1245,15 +1501,21 @@ export default SlackFunction( if (!await canAccessAdminFeature()) return {}; const selectedMenu: string = action.selected_option.value; if (selectedMenu === AdminMenuItem.AdminReportDownload) { - await slackApi.views.update({ + const result = await slackApi.views.update({ view_id: view.id, view: toAdminReportDownloadView({ view: newView(language), ...components, }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); } else if (selectedMenu === AdminMenuItem.ProjectSettings) { - await slackApi.views.update({ + const result = await slackApi.views.update({ view_id: view.id, view: toProjectMainView({ view: newView(language), @@ -1261,8 +1523,14 @@ export default SlackFunction( ...components, }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); } else if (selectedMenu === AdminMenuItem.OrganizationPolicies) { - await slackApi.views.update({ + const result = await slackApi.views.update({ view_id: view.id, view: toOrganizationPoliciesView({ view: newView(language), @@ -1270,6 +1538,12 @@ export default SlackFunction( ...components, }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); } return {}; } catch (e) { @@ -1371,10 +1645,16 @@ export default SlackFunction( const { user, slackApi, canAccessAdminFeature } = components; try { if (!await canAccessAdminFeature()) return {}; - await slackApi.views.push({ + const result = await slackApi.views.push({ trigger_id: body.interactivity.interactivity_pointer, view: newAddProjectView({ ...components }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = @@ -1417,9 +1697,19 @@ export default SlackFunction( // To deal with the eventual consistency of datastore if (!projects.find((p) => p.code === saved.code)) projects.push(saved); projects.sort((a, b) => a.code > b.code ? 1 : -1); - await syncProjectMainView( + const result = await syncProjectMainView( { viewId: view.previous_view_id, projects, ...components }, ); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); return {}; } catch (e) { const error = @@ -1439,10 +1729,16 @@ export default SlackFunction( const code: string = action.value; const project = await fetchProject({ code, ...components }); if (!project.code) return {}; - await slackApi.views.push({ + const result = await slackApi.views.push({ trigger_id: body.interactivity.interactivity_pointer, view: newEditProjectView({ code, project, ...components }), }); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); return {}; } catch (e) { const error = @@ -1492,9 +1788,19 @@ export default SlackFunction( } projects.sort((a, b) => a.code > b.code ? 1 : -1); - await syncProjectMainView( + const result = await syncProjectMainView( { viewId: view.previous_view_id, projects, ...components }, ); + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); + await deleteClosingOneFromActiveViews({ + view_id: view.id, + ...components, + }); return {}; } catch (e) { const error = @@ -1552,7 +1858,7 @@ export default SlackFunction( return {}; } await op.save({ attributes: { key, value } }); - await slackApi.views.update({ + const result = await slackApi.views.update({ view_id: body.view.id, view: toOrganizationPoliciesView({ view: newView(language), @@ -1560,6 +1866,14 @@ export default SlackFunction( ...components, }), }); + // This user is still on this view + await saveLastActiveView({ + callback_id: result.view!.callback_id!, + view_id: result.view!.id!, + user_id: user, + ...components, + }); + const lifelog = isLifelogEnabled ? await fetchLifelog({ ...components }) : undefined; diff --git a/manifest.ts b/manifest.ts index 911549f..fa084a6 100644 --- a/manifest.ts +++ b/manifest.ts @@ -1,5 +1,7 @@ import { Manifest } from "deno-slack-sdk/mod.ts"; import Timesheet from "./workflows/timesheet.ts"; +import ActiveViewRefresher from "./workflows/active_view_refresher.ts"; +import ActiveViewRefresherOnce from "./workflows/active_view_refresher_once.ts"; import TimeEntries from "./datastores/time_entries.ts"; import UserSettings from "./datastores/user_settings.ts"; @@ -9,6 +11,7 @@ import AdminUsers from "./datastores/admin_users.ts"; import Projects from "./datastores/projects.ts"; import OrganizationPolicies from "./datastores/organization_policies.ts"; import Lifelogs from "./datastores/lifelogs.ts"; +import ActiveViews from "./datastores/active_views.ts"; export default Manifest({ name: "Timesheet", @@ -19,6 +22,8 @@ export default Manifest({ ], workflows: [ Timesheet, + ActiveViewRefresher, + ActiveViewRefresherOnce, ], datastores: [ TimeEntries, @@ -29,6 +34,7 @@ export default Manifest({ AdminUsers, OrganizationPolicies, Lifelogs, + ActiveViews, ], botScopes: [ "commands", diff --git a/triggers/refresh_active_views_once.ts b/triggers/refresh_active_views_once.ts new file mode 100644 index 0000000..1255979 --- /dev/null +++ b/triggers/refresh_active_views_once.ts @@ -0,0 +1,12 @@ +import { Trigger } from "deno-slack-sdk/types.ts"; +import { TriggerTypes } from "deno-slack-api/mod.ts"; +import workflow from "../workflows/active_view_refresher_once.ts"; + +const trigger: Trigger = { + type: TriggerTypes.Shortcut, + name: "Refresh Active Views", + description: "Refresh all the active views now", + workflow: `#/workflows/${workflow.definition.callback_id}`, + inputs: {}, +}; +export default trigger; diff --git a/triggers/start_active_view_refresher.ts b/triggers/start_active_view_refresher.ts new file mode 100644 index 0000000..5e1ed2a --- /dev/null +++ b/triggers/start_active_view_refresher.ts @@ -0,0 +1,17 @@ +import { Trigger } from "deno-slack-sdk/types.ts"; +import { TriggerTypes } from "deno-slack-api/mod.ts"; +import workflow from "../workflows/active_view_refresher.ts"; + +const trigger: Trigger = { + type: TriggerTypes.Scheduled, + name: "Active View Refresher", + description: "Start a periodical job to refresh active views", + workflow: `#/workflows/${workflow.definition.callback_id}`, + schedule: { + // This start_time means 5 seconds after you run `slack trigger create` command + start_time: new Date(new Date().getTime() + 5_000).toISOString(), + frequency: { type: "daily" }, + }, + inputs: {}, +}; +export default trigger; diff --git a/workflows/active_view_refresher.ts b/workflows/active_view_refresher.ts new file mode 100644 index 0000000..dc0eda1 --- /dev/null +++ b/workflows/active_view_refresher.ts @@ -0,0 +1,19 @@ +import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts"; +import { def as RefreshMainViews } from "../functions/refresh_main_views.ts"; + +const workflow = DefineWorkflow({ + callback_id: "active_view_refresher", + title: "Active View Refresher", + description: "Workflow to refresh active views", + input_parameters: { properties: {}, required: [] }, +}); + +const timesPerHour = 2; +for (let i = 0; i < timesPerHour * 24; i++) { + workflow.addStep(RefreshMainViews, {}); + workflow.addStep(Schema.slack.functions.Delay, { + minutes_to_delay: 60 / timesPerHour, + }); +} + +export default workflow; diff --git a/workflows/active_view_refresher_once.ts b/workflows/active_view_refresher_once.ts new file mode 100644 index 0000000..0fc0a19 --- /dev/null +++ b/workflows/active_view_refresher_once.ts @@ -0,0 +1,13 @@ +import { DefineWorkflow } from "deno-slack-sdk/mod.ts"; +import { def as RefreshMainViews } from "../functions/refresh_main_views.ts"; + +const workflow = DefineWorkflow({ + callback_id: "active_view_refresher_once", + title: "Active View Refresher (once)", + description: "Workflow to refresh active views", + input_parameters: { properties: {}, required: [] }, +}); + +workflow.addStep(RefreshMainViews, {}); + +export default workflow;