src/models/m_rsc_gone.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2012-2026 Marc Worrell
%% @doc Model for administration of deleted resources and their possible new location.
%% @end

%% Copyright 2012-2026 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(m_rsc_gone).
-moduledoc("
This model tracks deleted resources (see [m_rsc](/id/doc_model_model_rsc)). Its primary goal is to be able to
determine if a resource never existed, has been deleted or has been replaced by another resource.



Information kept
----------------

Only very basic information of the deleted resource is kept in the `rsc_gone` table. It is enough for referring to a new
location, giving correct errors or to determine who deleted a resource.

It is not enough to undelete a resource. The module [mod_backup](/id/doc_module_mod_backup) retains enough information
about past versions to be able to undelete a resource. Currently there is no support for an undelete.



Properties
----------

Whenever a [m_rsc](/id/doc_model_model_rsc) record is deleted some information from that resource is copied to the
`rsc_gone` table.

The following properties are saved:

| Property           | Description                                                                      | Example value                         |
| ------------------ | -------------------------------------------------------------------------------- | ------------------------------------- |
| id | Id of the resource, an integer. | `42` |
| new_id | If the resource is replaced by another resource then this is the id of that other resource. | `341` |
| new_uri | If the resource is moved to another place on the web then this is the uri of the new location. | `<<\"<http://example.com/hello>\">>` |
| name | The name (if any) of the deleted resource. | `<<\"page_hello\">>` |
| uri | The uri of the authoritative source for the resource. | `<<\"<http://foo.bar/hello>\">>` |
| page_path | The page path (if any) of the deleted resource. | `<<\"/hello\">>` |
| is_authoritative | Whether the resource originated on this site or was imported and maintained on another site. Return a boolean. | `true` |
| creator_id | The id of the creator of the deleted resource. | `1` |
| created | The date the deleted resource was created. | `{{2008,12,10},{15,30,00}}` |
| modifier_id | The id of the user that deleted the resource. | `2718` |
| modified | The date the resource was deleted. | `{{2012,12,5},{23,59,59}}` |

Available Model API Paths
-------------------------

| Method | Path pattern | Description |
| --- | --- | --- |
| `get` | `/+id/new_location/...` | Return category data for +id. Uses `get_new_location`. |
| `get` | `/+id/is_gone/...` | Return whether gone (`is_gone`). |

`/+name` marks a variable path segment. A trailing `/...` means extra path segments are accepted for further lookups.

See also

[The Zotonic data model](/id/doc_userguide_datamodel#guide-datamodel), [m_rsc](/id/doc_model_model_rsc)").
-author("Marc Worrell <marc@worrell.nl").

-behaviour(zotonic_model).

%% interface functions
-export([
    m_get/3,

    get/2,
    get_uri/2,
    get_new_location/2,
    is_gone/2,
    is_gone_uri/2,
    gone/2,
    gone/3,

    delete/2,

    install/1
]).

-include_lib("zotonic.hrl").
-include_lib("epgsql/include/epgsql.hrl").


%% @doc Fetch the value for the key from a model source
-spec m_get( list(), zotonic_model:opt_msg(), z:context() ) -> zotonic_model:return().
m_get([ Id, <<"new_location">> | Rest ], _Msg, Context) ->
    {ok, {get_new_location(Id, Context), Rest}};
m_get([ Id, <<"is_gone">> | Rest ], _Msg, Context) ->
    {ok, {is_gone(Id, Context), Rest}};
m_get(_Vs, _Msg, _Context) ->
    {error, unknown_path}.

%% @doc Get the possible 'rsc_gone' resource for the id.
get(Id, Context) when is_integer(Id) ->
    F = fun() ->
        z_db:assoc_row("select * from rsc_gone where id = $1", [Id], Context)
    end,
    z_depcache:memo(F, {rsc_gone, Id}, Context);
get(Id, Context) ->
    get(m_rsc:rid(Id, Context), Context).


%% @doc Get the possible 'rsc_gone' resource for the uri.
-spec get_uri( binary() | string(), z:context() ) -> proplists:proplist() | undefined.
get_uri(Uri, Context) when is_binary(Uri); is_list(Uri) ->
    z_db:assoc_row("select * from rsc_gone where uri = $1", [Uri], Context);
get_uri(_, _Context) ->
    undefined.


%% @doc Get the redirect location for the id, uses the current dispatch rule and otherwise
%%      the 'id' dispatch rule.
get_new_location(undefined, _Context) ->
    undefined;
get_new_location(Id, Context) when is_integer(Id) ->
    F = fun() ->
            z_db:q_row("select new_id, new_uri from rsc_gone where id = $1 limit 1", [Id], Context)
        end,
    case z_depcache:memo(F, {rsc_gone_new_location, Id}, Context) of
        undefined ->
            undefined;
        {undefined, undefined} ->
            undefined;
        {NewId, _} when is_integer(NewId) ->
            NewUri = case z_context:get(zotonic_dispatch, Context) of
                         undefined ->
                             z_dispatcher:url_for(id, [{id, NewId}], Context);
                         Dispatch ->
                             case z_dispatcher:url_for(Dispatch, [{id, NewId}], Context) of
                                 undefined -> z_dispatcher:url_for(id, [{id, NewId}], Context);
                                 DispUri -> DispUri
                             end
                     end,
            z_context:abs_url(NewUri, Context);
        {undefined, NewUri} ->
            z_context:abs_url(NewUri, Context)
    end.


%% @doc Check if the resource used to exist.
-spec is_gone(m_rsc:resource_id()|undefined, z:context()) -> boolean().
is_gone(undefined, _Context) ->
    false;
is_gone(Id, Context) when is_integer(Id) ->
    F = fun() ->
            z_db:q1("select count(*) from rsc_gone where id = $1", [Id], Context) =:= 1
        end,
    z_depcache:memo(F, {rsc_is_gone, Id}, Context).

%% @doc Check if the resource uri used to exist.
-spec is_gone_uri(string()|binary()|undefined, z:context()) -> boolean().
is_gone_uri(undefined, _Context) ->
    false;
is_gone_uri(Uri, Context) ->
    UriB = unicode:characters_to_binary(Uri, utf8),
    F = fun() ->
            z_db:q1("select count(*) from rsc_gone where uri = $1", [UriB], Context) >= 1
        end,
    z_depcache:memo(F, {rsc_is_gone, UriB}, Context).


%% @doc Copy a resource to the 'gone' table, use the current user as the modifier (deleter).
-spec gone(m_rsc:resource_id(), z:context()) -> {ok, integer()} | {error, term()}.
gone(Id, Context) when is_integer(Id) ->
    gone(Id, undefined, Context).

%% @doc Copy a resource to the 'gone' table, use the current user as the modifier (deleter).
%%      Also sets the 'new id', which is the id that replaces the deleted id.
gone(Id, NewId, Context) when is_integer(Id), is_integer(NewId) orelse NewId =:= undefined ->
    case z_db:assoc_row("
            select id, is_authoritative, name, version,
                   pivot_page_path as page_paths, uri,
                   creator_id, created
            from rsc
            where id = $1
            ", [Id], Context)
    of
        undefined ->
            {error, notfound};
        Props when is_list(Props) ->
            Result = z_db:transaction(
                    fun(Ctx) ->
                        Props1 = [
                            {new_id, NewId},
                            {new_uri, undefined},
                            {modifier_id, z_acl:user(Ctx)},
                            {is_personal_data, m_identity:is_user(Id, Context)}
                            | Props
                        ],
                        case z_db:q1("select count(*) from rsc_gone where id = $1", [Id], Ctx) of
                            1 ->
                                {ok, _} = z_db:update(rsc_gone, Id, Props1, Ctx),
                                {ok, Id};
                            0 ->
                                % Force previous versions to refer to the new resource
                                z_db:q("
                                    update rsc_gone
                                    set new_id = $1
                                    where new_id = $2
                                    ",
                                    [ NewId, Id ],
                                    Ctx),
                                z_db:insert(rsc_gone, Props1, Ctx)
                        end
                    end,
                    Context),
            case Result of
                {error, #error{ codename = unique_violation }} ->
                    % Duplicate key - ignore (race condition)
                    {ok, Id};
                Other -> Other
            end
    end.

%% @doc Delete a gone entry for a resource, used after recovery of a resource.
-spec delete(Id, Context) -> ok | {error, enoent} when
    Id :: m_rsc:resource_id(),
    Context :: z:context().
delete(Id, Context) ->
    case z_db:q("delete from rsc_gone where id = $1", [ Id ], Context) of
        1 -> ok;
        0 -> {error, enoent}
    end.

%% @doc Install or upgrade the rsc_gone table.
-spec install( z:context() ) -> ok.
install(Context) ->
    % Table: rsc_gone
    % Tracks deleted or moved resources, adding "410 gone" support
    % Also contains new id or new url for 301 moved permanently replies.
    % mod_backup is needed to recover a deleted resource's content.
    case z_db:table_exists(rsc_gone, Context) of
        false ->
            [] = z_db:q("
                CREATE TABLE IF NOT EXISTS rsc_gone
                (
                    id bigint not null,
                    new_id bigint,
                    new_uri character varying(2048),
                    version int not null,
                    uri character varying(2048),
                    name character varying(80),
                    page_paths character varying(80)[],
                    is_authoritative boolean NOT NULL DEFAULT true,
                    creator_id bigint,
                    modifier_id bigint,
                    created timestamp with time zone NOT NULL DEFAULT now(),
                    modified timestamp with time zone NOT NULL DEFAULT now(),
                    is_personal_data boolean NOT NULL DEFAULT false,
                    CONSTRAINT rsc_gone_pkey PRIMARY KEY (id)
                )",
                Context),

            [] = z_db:q("CREATE INDEX IF NOT EXISTS rsc_gone_name_key ON rsc_gone(name)", Context),
            [] = z_db:q("CREATE INDEX IF NOT EXISTS rsc_gone_uri_key ON rsc_gone(uri)", Context),
            [] = z_db:q("CREATE INDEX IF NOT EXISTS rsc_gone_page_paths_key ON rsc_gone USING gin(page_paths)", Context),
            [] = z_db:q("CREATE INDEX IF NOT EXISTS rsc_gone_modified_key ON rsc_gone(modified)", Context),
            z_db:flush(Context),
            ok;
        true ->
            % Check for rsc_gone_uri_key
            case z_db:key_exists(rsc_gone, rsc_gone_uri_key, Context) of
                false ->
                    [] = z_db:q("CREATE INDEX IF NOT EXISTS rsc_gone_uri_key ON rsc_gone(uri)", Context),
                    ok;
                true ->
                    ok
            end,
            % Check for is_personal_data column
            case z_db:column_exists(rsc_gone, is_personal_data, Context) of
                false ->
                    [] = z_db:q("ALTER TABLE rsc_gone ADD COLUMN is_personal_data boolean NOT NULL DEFAULT false", Context),
                    z_db:flush(Context);
                true ->
                    ok
            end,
            % Check for page_paths column
            case z_db:column_exists(rsc_gone, page_paths, Context) of
                false ->
                    [] = z_db:q("ALTER TABLE rsc_gone ADD COLUMN page_paths character varying(80)[]", Context),
                    z_db:q("update rsc_gone
                            set page_paths = array[page_path]
                            where page_path is not null", Context),
                    [] = z_db:q("ALTER TABLE rsc_gone DROP COLUMN page_path CASCADE", Context),
                    [] = z_db:q("CREATE INDEX IF NOT EXISTS rsc_gone_page_paths_key ON rsc_gone USING gin(page_paths)", Context),
                    z_db:flush(Context);
                true ->
                    ok
            end
    end.